From 9c3277fd6191345b805c19ecaaff7b78c4b8d0ca Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 11:25:09 +1100 Subject: [PATCH 01/22] docs(plans): complete Go examples for the plan builder Adds docs/plans/2026-10-04-plan-builder/: complete Go programs that use the Go binding the plan describes, with a README that lists what each file shows. Why complete programs: a snippet hides the questions a Go caller asks first. Where does the plan live? What type does each call return? How does a value reach a database column? Each file answers them in context. Why three database stores: database/sql, GORM and sqlc are the usual ways Go code reaches Postgres. Each store encrypts before the driver sees a value, because driver.Valuer gets no context.Context and runs one field at a time, so it can neither batch a ZeroKMS request nor stop on cancellation. The sqlc packages are real `sqlc generate` output (sqlc v1.31.1, sha256 checked against the release asset digest). Running it confirmed three things the examples rely on: - go_struct_tag works on a column override that has no go_type; - the params structs carry the stash tags, not only the model; - userdb.CreateUserParams(row) converts from the generated model. The EQL sqlc variant records what running sqlc against EQL showed: - sqlc cannot parse the EQL install bundle. A bisection over its 6,434 statements stopped at line 2829, an eql_v3_internal."-" overload that differs from its sibling only by text versus text[]. Two plain functions, minus(jsonb, text) and minus(jsonb, text[]), reproduce `relation "minus" already exists`. Quoting and DO blocks are not the cause. The workaround is a file that declares the domains for sqlc. - A db_type override matches only the exact spelling in the schema: public.eql_v3_text_search and eql_v3_text_search are two spellings, and the wrong one leaves the column as interface{}. - sqlc types a parameter by its first cast, so the query casts once, straight to the query domain. $1::jsonb::eql_v3.query_text_search generates json.RawMessage. The interface does not exist yet, so nothing here builds in this repository. Every Go file type-checks (`go vet ./...`) against a signature-only stub of the proposed API, with real gorm.io/gorm v1.31.2 and github.com/jackc/pgx/v5 v5.11.0, and gofmt reports nothing. The stub is not committed. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder/README.md | 41 +++++ .../blocklist/blocklist.go | 66 ++++++++ .../contacts/contacts.go | 45 +++++ .../documents/documents.go | 46 +++++ .../eql-sqlc/eql-domains.sql | 10 ++ .../eql-sqlc/query.sql | 13 ++ .../eql-sqlc/schema.sql | 6 + .../eql-sqlc/sqlc.yaml | 23 +++ .../individuals/individuals.go | 96 +++++++++++ .../internal/eqldb/db.go | 31 ++++ .../internal/eqldb/models.go | 16 ++ .../internal/eqldb/query.sql.go | 131 ++++++++++++++ .../internal/userdb/db.go | 31 ++++ .../internal/userdb/models.go | 21 +++ .../internal/userdb/query.sql.go | 160 ++++++++++++++++++ docs/plans/2026-10-04-plan-builder/main.go | 105 ++++++++++++ .../2026-10-04-plan-builder/sqlc/query.sql | 15 ++ .../2026-10-04-plan-builder/sqlc/schema.sql | 13 ++ .../2026-10-04-plan-builder/sqlc/sqlc.yaml | 37 ++++ .../2026-10-04-plan-builder/users/extend.go | 23 +++ .../users/gormstore.go | 43 +++++ .../2026-10-04-plan-builder/users/model.go | 44 +++++ .../users/sqlcstore.go | 116 +++++++++++++ .../2026-10-04-plan-builder/users/sqlstore.go | 134 +++++++++++++++ 24 files changed, 1266 insertions(+) create mode 100644 docs/plans/2026-10-04-plan-builder/README.md create mode 100644 docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go create mode 100644 docs/plans/2026-10-04-plan-builder/contacts/contacts.go create mode 100644 docs/plans/2026-10-04-plan-builder/documents/documents.go create mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql create mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql create mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql create mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml create mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individuals.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/db.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/models.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go create mode 100644 docs/plans/2026-10-04-plan-builder/main.go create mode 100644 docs/plans/2026-10-04-plan-builder/sqlc/query.sql create mode 100644 docs/plans/2026-10-04-plan-builder/sqlc/schema.sql create mode 100644 docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml create mode 100644 docs/plans/2026-10-04-plan-builder/users/extend.go create mode 100644 docs/plans/2026-10-04-plan-builder/users/gormstore.go create mode 100644 docs/plans/2026-10-04-plan-builder/users/model.go create mode 100644 docs/plans/2026-10-04-plan-builder/users/sqlcstore.go create mode 100644 docs/plans/2026-10-04-plan-builder/users/sqlstore.go diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md new file mode 100644 index 000000000..eac4a66bf --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -0,0 +1,41 @@ +# Go examples for the plan builder + +These files show the Go binding from [the plan builder design](../2026-10-04-plan-builder.md#the-go-binding) as complete programs. +The binding does not have this interface yet, so this code does not build in this repository. +The module path is `example.com/app`. + +## What each file shows + +| File | What it shows | +|---|---| +| [`main.go`](main.go) | A client, one keyset for each tenant, and calls into every package below | +| [`users/model.go`](users/model.go) | A plan in struct tags, a storage struct, and the plans held in package-level variables | +| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql`: insert, batch insert in a transaction, equality search, ORE ordering and JSON containment | +| [`users/gormstore.go`](users/gormstore.go) | GORM, with the storage struct as the model | +| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, including an update of one field and its terms | +| [`users/extend.go`](users/extend.go) | A context extension on the write, the query and the read | +| [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | +| [`contacts/contacts.go`](contacts/contacts.go) | A plan built by hand for a type with no tags | +| [`individuals/individuals.go`](individuals/individuals.go) | A plan from the policy package, for a type that cannot carry tags | +| [`blocklist/blocklist.go`](blocklist/blocklist.go) | A value plan for a value with no record around it | +| [`documents/documents.go`](documents/documents.go) | One value sealed as one tree, decrypted with the client | +| [`eql-sqlc/`](eql-sqlc/) | sqlc with EQL v3 domain columns, which generates [`internal/eqldb/`](internal/eqldb/) | + +## Generate the sqlc packages + +The two sqlc packages were generated with sqlc v1.31.1. +Run `sqlc generate` in `sqlc/` and in `eql-sqlc/` to generate them again. + +## Use sqlc with EQL domain columns + +Three rules apply when a column has an EQL v3 domain type: + +- **Give sqlc a file that declares the domains.** + sqlc cannot parse the EQL install bundle, because it rejects function overloads that differ only by `text` and `text[]`. + The file [`eql-sqlc/eql-domains.sql`](eql-sqlc/eql-domains.sql) declares each domain as `jsonb`, and the database still gets the real bundle from `stash eql install`. +- **Spell a `db_type` override exactly as the schema spells the type.** + `public.eql_v3_text_search` and `eql_v3_text_search` are two different spellings to sqlc. + An override with the other spelling matches nothing, and the column becomes `interface{}`. +- **Cast a query parameter once, straight to the query domain.** + sqlc types a parameter by its first cast. + `$1::eql_v3.query_text_search` gets the override type, and `$1::jsonb::eql_v3.query_text_search` gets `json.RawMessage`. diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go new file mode 100644 index 000000000..3c65b58e8 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go @@ -0,0 +1,66 @@ +// Package blocklist keeps blocked email addresses. Each address is one value +// with no record around it, so it uses a value plan. +package blocklist + +import ( + "context" + "database/sql" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +var blockedPlan = stackencrypt.MustValuePlan[string]("blocked_emails/email", stackencrypt.Equality) + +type List struct { + db *sql.DB + cipher *stackencrypt.Cipher +} + +func New(db *sql.DB, cipher *stackencrypt.Cipher) *List { + return &List{db: db, cipher: cipher} +} + +func (l *List) Block(ctx context.Context, email string) error { + field, err := blockedPlan.Encrypt(ctx, l.cipher, email) + if err != nil { + return err + } + _, err = l.db.ExecContext(ctx, + `INSERT INTO blocked_emails (email, email_eq) VALUES ($1, $2) ON CONFLICT (email_eq) DO NOTHING`, + field.Ciphertext, field.Equality) + return err +} + +func (l *List) Blocked(ctx context.Context, email string) (bool, error) { + term, err := blockedPlan.Equality(ctx, l.cipher, email) + if err != nil { + return false, err + } + var blocked bool + err = l.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM blocked_emails WHERE email_eq = $1)`, term).Scan(&blocked) + return blocked, err +} + +// All returns every blocked address, for review. Decryption reads only the +// ciphertext column. +func (l *List) All(ctx context.Context) ([]string, error) { + rs, err := l.db.QueryContext(ctx, `SELECT email FROM blocked_emails`) + if err != nil { + return nil, err + } + defer rs.Close() + + var fields []stackencrypt.EncryptedField + for rs.Next() { + var sealed stackencrypt.Ciphertext + if err := rs.Scan(&sealed); err != nil { + return nil, err + } + fields = append(fields, stackencrypt.EncryptedField{Ciphertext: sealed}) + } + if err := rs.Err(); err != nil { + return nil, err + } + return blockedPlan.DecryptAll(ctx, l.cipher, fields) +} diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go new file mode 100644 index 000000000..bde7627fe --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -0,0 +1,45 @@ +// Package contacts builds a plan by hand for a type with no stash tags. +package contacts + +import ( + "context" + "database/sql" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +// Contact has no stash tags. Bind matches each plan field to the struct field +// whose name, in snake_case, is the plan field's name. +type Contact struct { + ID int64 + Email string + PhoneNumber string + Internal string +} + +var contactsPlan = stackencrypt.MustBind[Contact](stackencrypt.NewPlan("contacts"). + Passthrough("id"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("phone_number", stackencrypt.Equality). + Omit("internal"). + MustBuild()) + +func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, contact Contact) error { + record, err := contactsPlan.Encrypt(ctx, cipher, contact) + if err != nil { + return err + } + email, err := record.Field("email") + if err != nil { + return err + } + phone, err := record.Field("phone_number") + if err != nil { + return err + } + _, err = db.ExecContext(ctx, ` + INSERT INTO contacts (id, email, email_eq, email_match, phone_number, phone_number_eq) + VALUES ($1, $2, $3, $4, $5, $6)`, + contact.ID, email.Ciphertext, email.Equality, email.Match, phone.Ciphertext, phone.Equality) + return err +} diff --git a/docs/plans/2026-10-04-plan-builder/documents/documents.go b/docs/plans/2026-10-04-plan-builder/documents/documents.go new file mode 100644 index 000000000..92ab021b0 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/documents/documents.go @@ -0,0 +1,46 @@ +// Package documents seals each document as one tree under one context. A +// document is stored and read whole, so it needs no plan. +package documents + +import ( + "context" + "database/sql" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +type Document struct { + Title string + Body string + Tags []string +} + +var bodyContext = mustLabel("documents", "v2", "body").Context() + +func mustLabel(segments ...string) stackencrypt.Label { + label, err := stackencrypt.NewLabel(segments...) + if err != nil { + panic(err) + } + return label +} + +func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64, doc Document) error { + sealed, err := cipher.Encrypt(ctx, doc, bodyContext) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, `INSERT INTO documents (id, body) VALUES ($1, $2)`, id, sealed) + return err +} + +// Load decrypts with the client, which opens each leaf under the keyset that +// sealed it. A *stackencrypt.Cipher would also refuse a leaf from another +// keyset. +func Load(ctx context.Context, db *sql.DB, client *stackencrypt.Client, id int64) (Document, error) { + var sealed stackencrypt.Ciphertext + if err := db.QueryRowContext(ctx, `SELECT body FROM documents WHERE id = $1`, id).Scan(&sealed); err != nil { + return Document{}, err + } + return stackencrypt.DecryptValue[Document](ctx, client, sealed, bodyContext) +} diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql b/docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql new file mode 100644 index 000000000..1b93ae49e --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql @@ -0,0 +1,10 @@ +-- For sqlc only: sqlc cannot parse the EQL install bundle. Spell each domain +-- exactly as schema.sql and query.sql do, or its db_type override never matches. +CREATE SCHEMA eql_v3; + +CREATE DOMAIN public.eql_v3_text_search AS jsonb; +CREATE DOMAIN public.eql_v3_integer_ord AS jsonb; +CREATE DOMAIN public.eql_v3_json_search AS jsonb; + +CREATE DOMAIN eql_v3.query_text_search AS jsonb; +CREATE DOMAIN eql_v3.query_integer_ord AS jsonb; diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql new file mode 100644 index 000000000..ccc33ca35 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql @@ -0,0 +1,13 @@ +-- name: CreateUser :exec +INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4); + +-- One cast, straight to the query domain: sqlc types a parameter by its first +-- cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. +-- name: FindUsersByEmail :many +SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_search; + +-- name: SearchUsersByEmail :many +SELECT * FROM users WHERE email @@ sqlc.arg(pattern)::eql_v3.query_text_search; + +-- name: ListUsersOlderThan :many +SELECT * FROM users WHERE age > sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql new file mode 100644 index 000000000..0a78502cd --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id bigint PRIMARY KEY, + email public.eql_v3_text_search NOT NULL, + age public.eql_v3_integer_ord NOT NULL, + attrs public.eql_v3_json_search NOT NULL +); diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml new file mode 100644 index 000000000..5b9cbaf44 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml @@ -0,0 +1,23 @@ +version: "2" +sql: + - engine: postgresql + schema: + - eql-domains.sql + - schema.sql + queries: query.sql + gen: + go: + package: eqldb + out: ../internal/eqldb + emit_db_tags: true + overrides: + - db_type: "public.eql_v3_text_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: TextSearch } + - db_type: "public.eql_v3_integer_ord" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: IntegerOrd } + - db_type: "public.eql_v3_json_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: JSONSearch } + - db_type: "eql_v3.query_text_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryTextSearch } + - db_type: "eql_v3.query_integer_ord" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryIntegerOrd } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individuals.go b/docs/plans/2026-10-04-plan-builder/individuals/individuals.go new file mode 100644 index 000000000..725ad053e --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individuals/individuals.go @@ -0,0 +1,96 @@ +// Package individuals stores a type that carries no stash tags. A policy +// decides each field from the data categories the schema gives it. +package individuals + +import ( + "context" + "database/sql" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +// Individual stands in for a type generated from a schema, such as a protobuf +// message, that cannot carry tags. +type Individual struct { + ID int64 + Name string + Email string + MedicareNo string + Nickname string +} + +var category = plan.Key("fides.data_categories") + +func categories(values ...string) []plan.Annotation { + return []plan.Annotation{{Key: string(category), Values: values}} +} + +// source states the facts a schema reader would produce. A protobuf source +// reads the same facts from descriptors and their custom options. +var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { + return []plan.Fact{ + {Field: "id", GoField: "ID"}, + {Field: "name", GoField: "Name", Annotations: categories("user.name")}, + {Field: "email", GoField: "Email", Annotations: categories("user.contact.email")}, + {Field: "medicare_no", GoField: "MedicareNo", Annotations: categories("user.government_id")}, + {Field: "nickname", GoField: "Nickname"}, + }, nil +}) + +var base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match()))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +var individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_number")), + ).OrElse(base), +) + +// The policy stores id and nickname as plaintext. The plan names them as +// omitted fields, so Bind accepts Individual. +var ( + individualsPlan = stackencrypt.MustBind[Individual](plan.MustPlanFor(source, individuals)) + medicarePlan = stackencrypt.MustField[string](individualsPlan, "medicare_number") +) + +func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person Individual) error { + record, err := individualsPlan.Encrypt(ctx, cipher, person) + if err != nil { + return err + } + // Field returns an error for a name the plan does not have, never a zero + // value that would write NULL. + name, err := record.Field("name") + if err != nil { + return err + } + email, err := record.Field("email") + if err != nil { + return err + } + medicare, err := record.Field("medicare_number") + if err != nil { + return err + } + _, err = db.ExecContext(ctx, ` + INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + person.ID, person.Nickname, name.Ciphertext, email.Ciphertext, email.Equality, email.Match, + medicare.Ciphertext, medicare.Equality) + return err +} + +func IDByMedicare(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, medicareNo string) (int64, error) { + term, err := medicarePlan.Equality(ctx, cipher, medicareNo) + if err != nil { + return 0, err + } + var id int64 + err = db.QueryRowContext(ctx, `SELECT id FROM individuals WHERE medicare_number_eq = $1`, term).Scan(&id) + return id, err +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go new file mode 100644 index 000000000..4f8ab5ecf --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package eqldb + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go new file mode 100644 index 000000000..f7ba50326 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go @@ -0,0 +1,16 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package eqldb + +import ( + "github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3" +) + +type User struct { + ID int64 `db:"id"` + Email eqlv3.TextSearch `db:"email"` + Age eqlv3.IntegerOrd `db:"age"` + Attrs eqlv3.JSONSearch `db:"attrs"` +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go new file mode 100644 index 000000000..762c18835 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go @@ -0,0 +1,131 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package eqldb + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3" +) + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4) +` + +type CreateUserParams struct { + ID int64 `db:"id"` + Email eqlv3.TextSearch `db:"email"` + Age eqlv3.IntegerOrd `db:"age"` + Attrs eqlv3.JSONSearch `db:"attrs"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Email, + arg.Age, + arg.Attrs, + ) + return err +} + +const findUsersByEmail = `-- name: FindUsersByEmail :many +SELECT id, email, age, attrs FROM users WHERE email = $1::eql_v3.query_text_search +` + +// One cast, straight to the query domain: sqlc types a parameter by its first +// cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. +func (q *Queries) FindUsersByEmail(ctx context.Context, email eqlv3.QueryTextSearch) ([]User, error) { + rows, err := q.db.QueryContext(ctx, findUsersByEmail, email) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersOlderThan = `-- name: ListUsersOlderThan :many +SELECT id, email, age, attrs FROM users WHERE age > $1::eql_v3.query_integer_ord ORDER BY age +` + +func (q *Queries) ListUsersOlderThan(ctx context.Context, minAge eqlv3.QueryIntegerOrd) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsersOlderThan, minAge) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchUsersByEmail = `-- name: SearchUsersByEmail :many +SELECT id, email, age, attrs FROM users WHERE email @@ $1::eql_v3.query_text_search +` + +func (q *Queries) SearchUsersByEmail(ctx context.Context, pattern eqlv3.QueryTextSearch) ([]User, error) { + rows, err := q.db.QueryContext(ctx, searchUsersByEmail, pattern) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go new file mode 100644 index 000000000..7e793aebb --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package userdb + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go new file mode 100644 index 000000000..604242b2d --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package userdb + +import ( + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +type User struct { + ID int64 `db:"id" stash:"id"` + Email stackencrypt.Ciphertext `db:"email" stash:"email"` + EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` + Age stackencrypt.Ciphertext `db:"age" stash:"age"` + AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` + AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` + Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` + Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go new file mode 100644 index 000000000..9f8602131 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package userdb + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +` + +type CreateUserParams struct { + ID int64 `db:"id" stash:"id"` + Email stackencrypt.Ciphertext `db:"email" stash:"email"` + EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` + Age stackencrypt.Ciphertext `db:"age" stash:"age"` + AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` + AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` + Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` + Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Email, + arg.EmailEq, + arg.EmailMatch, + arg.Age, + arg.AgeEq, + arg.AgeOre, + arg.Attrs, + arg.Notes, + ) + return err +} + +const findUsersByEmail = `-- name: FindUsersByEmail :many +SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users WHERE email_eq = $1 +` + +func (q *Queries) FindUsersByEmail(ctx context.Context, emailEq stackencrypt.EqualityTerm) ([]User, error) { + rows, err := q.db.QueryContext(ctx, findUsersByEmail, emailEq) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.EmailEq, + &i.EmailMatch, + &i.Age, + &i.AgeEq, + &i.AgeOre, + &i.Attrs, + &i.Notes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUser = `-- name: GetUser :one +SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users WHERE id = $1 +` + +func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRowContext(ctx, getUser, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.EmailEq, + &i.EmailMatch, + &i.Age, + &i.AgeEq, + &i.AgeOre, + &i.Attrs, + &i.Notes, + ) + return i, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users ORDER BY id +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.EmailEq, + &i.EmailMatch, + &i.Age, + &i.AgeEq, + &i.AgeOre, + &i.Attrs, + &i.Notes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserEmail = `-- name: UpdateUserEmail :exec +UPDATE users SET email = $2, email_eq = $3, email_match = $4 WHERE id = $1 +` + +type UpdateUserEmailParams struct { + ID int64 `db:"id" stash:"id"` + Email stackencrypt.Ciphertext `db:"email" stash:"email"` + EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` +} + +func (q *Queries) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) error { + _, err := q.db.ExecContext(ctx, updateUserEmail, + arg.ID, + arg.Email, + arg.EmailEq, + arg.EmailMatch, + ) + return err +} diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go new file mode 100644 index 000000000..7c54627be --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + + _ "github.com/jackc/pgx/v5/stdlib" + + "example.com/app/blocklist" + "example.com/app/documents" + "example.com/app/users" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +func main() { + if err := run(context.Background()); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { + client, err := stackencrypt.NewClient(ctx, stackencrypt.WithCredentials(stackencrypt.AutoCredentials())) + if err != nil { + return err + } + defer client.Close() + + db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) + if err != nil { + return err + } + defer db.Close() + + const tenant = "tenant-42" + cipher := client.Keyset(stackencrypt.KeysetName(tenant)) + store := users.NewSQLStore(db, client) + + alice := users.User{ + ID: 1, + Email: "alice@example.com", + Age: 34, + Attrs: map[string]any{"role": "admin", "team": "payments"}, + Notes: "Prefers email.", + Internal: "never stored", + } + newHires := []users.User{ + {ID: 2, Email: "bob@example.com", Age: 17, Attrs: map[string]any{"role": "intern"}, Notes: "Starts Monday."}, + {ID: 3, Email: "carol@example.com", Age: 52, Attrs: map[string]any{"role": "admin"}}, + } + + if err := store.Create(ctx, tenant, alice); err != nil { + return err + } + if err := store.Import(ctx, tenant, newHires); err != nil { + return err + } + + bobs, err := store.FindByEmail(ctx, tenant, "bob@example.com") + if err != nil { + return err + } + admins, err := store.WithRole(ctx, tenant, "admin") + if err != nil { + return err + } + adults, err := store.OldestFirst(ctx, tenant, 18) + if err != nil { + return err + } + if _, err := users.RoundTripForTenant(ctx, cipher, "tenant-42-region-ap", alice); err != nil { + return err + } + + blocked := blocklist.New(db, cipher) + if err := blocked.Block(ctx, "spam@example.net"); err != nil { + return err + } + isBlocked, err := blocked.Blocked(ctx, "spam@example.net") + if err != nil { + return err + } + + handbook := documents.Document{Title: "Handbook", Body: "Welcome aboard.", Tags: []string{"hr"}} + if err := documents.Save(ctx, db, cipher, 100, handbook); err != nil { + return err + } + if _, err := documents.Load(ctx, db, client, 100); err != nil { + return err + } + + // Print ids and counts only. Every other value here is plaintext. + fmt.Println("bob:", ids(bobs), "admins:", ids(admins), "adults, oldest first:", ids(adults), "blocked:", isBlocked) + return nil +} + +func ids(people []users.User) []int64 { + out := make([]int64, len(people)) + for i, p := range people { + out[i] = p.ID + } + return out +} diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql new file mode 100644 index 000000000..2ab429332 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql @@ -0,0 +1,15 @@ +-- name: CreateUser :exec +INSERT INTO users (id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + +-- name: GetUser :one +SELECT * FROM users WHERE id = $1; + +-- name: FindUsersByEmail :many +SELECT * FROM users WHERE email_eq = $1; + +-- name: ListUsers :many +SELECT * FROM users ORDER BY id; + +-- name: UpdateUserEmail :exec +UPDATE users SET email = $2, email_eq = $3, email_match = $4 WHERE id = $1; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql new file mode 100644 index 000000000..b2132a0cc --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql @@ -0,0 +1,13 @@ +CREATE TABLE users ( + id bigint PRIMARY KEY, + email bytea NOT NULL, + email_eq bytea NOT NULL, + email_match bytea NOT NULL, + age bytea NOT NULL, + age_eq bytea NOT NULL, + age_ore bytea NOT NULL, + attrs jsonb NOT NULL, + notes bytea NOT NULL +); + +CREATE INDEX users_email_eq ON users (email_eq); diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml new file mode 100644 index 000000000..9c295366f --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml @@ -0,0 +1,37 @@ +version: "2" +sql: + - engine: postgresql + schema: schema.sql + queries: query.sql + gen: + go: + package: userdb + out: ../internal/userdb + emit_db_tags: true + overrides: + - column: users.id + go_struct_tag: 'stash:"id"' + - column: users.email + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } + go_struct_tag: 'stash:"email"' + - column: users.email_eq + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: EqualityTerm } + go_struct_tag: 'stash:"email,equality"' + - column: users.email_match + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: MatchTerm } + go_struct_tag: 'stash:"email,match"' + - column: users.age + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } + go_struct_tag: 'stash:"age"' + - column: users.age_eq + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: EqualityTerm } + go_struct_tag: 'stash:"age,equality"' + - column: users.age_ore + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: OreTerm } + go_struct_tag: 'stash:"age,ore"' + - column: users.attrs + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: JSONDocument } + go_struct_tag: 'stash:"attrs,json"' + - column: users.notes + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } + go_struct_tag: 'stash:"notes"' diff --git a/docs/plans/2026-10-04-plan-builder/users/extend.go b/docs/plans/2026-10-04-plan-builder/users/extend.go new file mode 100644 index 000000000..0ac6589c0 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/extend.go @@ -0,0 +1,23 @@ +package users + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +// RoundTripForTenant shows a context extension. The parts extend every field's +// context, so the write, the query and the read must pass the same parts. +// With other parts, decryption fails and the equality term matches nothing. +func RoundTripForTenant(ctx context.Context, cipher *stackencrypt.Cipher, part string, user User) (User, error) { + extend := stackencrypt.ExtendContext(part) + + record, err := usersPlan.Encrypt(ctx, cipher, user, extend) + if err != nil { + return User{}, err + } + if _, err := emailPlan.Equality(ctx, cipher, user.Email, extend); err != nil { + return User{}, err + } + return usersPlan.Decrypt(ctx, cipher, record, extend) +} diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go new file mode 100644 index 000000000..38e3959e8 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -0,0 +1,43 @@ +package users + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "gorm.io/gorm" +) + +// GormStore encrypts before GORM sees a value. driver.Valuer gets no +// context.Context and runs one field at a time, so it cannot batch a ZeroKMS +// request or stop when the request is cancelled. An AfterFind hook works too, +// but it decrypts one row per ZeroKMS request. +type GormStore struct { + db *gorm.DB + client *stackencrypt.Client +} + +func NewGormStore(db *gorm.DB, client *stackencrypt.Client) *GormStore { + return &GormStore{db: db, client: client} +} + +func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) error { + cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) + rows, err := userRowPlan.EncryptAll(ctx, cipher, people) + if err != nil { + return err + } + return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error +} + +func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { + cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) + term, err := emailPlan.Equality(ctx, cipher, email) + if err != nil { + return nil, err + } + var rows []UserRow + if err := s.db.WithContext(ctx).Where("email_eq = ?", term).Find(&rows).Error; err != nil { + return nil, err + } + return userRowPlan.DecryptAll(ctx, cipher, rows) +} diff --git a/docs/plans/2026-10-04-plan-builder/users/model.go b/docs/plans/2026-10-04-plan-builder/users/model.go new file mode 100644 index 000000000..e8de82243 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/model.go @@ -0,0 +1,44 @@ +package users + +import "github.com/cipherstash/stack/languages/golang/stackencrypt" + +// User is the plaintext a caller works with. Its tags are the plan. PlanOf +// refuses an exported field with no stash tag, so a new field cannot reach the +// database unencrypted by accident. +type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + Age uint32 `stash:"age,encrypt,index=equality;ore"` + Attrs map[string]any `stash:"attrs,index=json"` + Notes string `stash:"notes,encrypt"` + Internal string `stash:"-"` +} + +// UserRow is the users table as stored. Every field is a driver.Valuer and an +// sql.Scanner. GORM's default naming maps EmailEq to email_eq; sqlx and scany +// read the db tags. +type UserRow struct { + ID int64 `db:"id" stash:"id"` + Email stackencrypt.Ciphertext `db:"email" stash:"email"` + EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` + Age stackencrypt.Ciphertext `db:"age" stash:"age"` + AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` + AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` + Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` + Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` +} + +func (UserRow) TableName() string { return "users" } + +// The plans are built and checked at package init. A bad tag, or a UserRow +// that does not cover every output of the plan, panics at startup instead of +// on the first request. +var ( + usersPlan = stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]()) + userRowPlan = stackencrypt.MustRowPlan[UserRow](usersPlan) + emailPlan = stackencrypt.MustField[string](usersPlan, "email") + agePlan = stackencrypt.MustField[uint32](usersPlan, "age") + attrsPlan = stackencrypt.MustField[map[string]any](usersPlan, "attrs") +) diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go new file mode 100644 index 000000000..e3b544f97 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -0,0 +1,116 @@ +package users + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "example.com/app/internal/userdb" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +var ErrNotFound = errors.New("users: not found") + +// The sqlc overrides put the same stash tags on userdb.User, so this plan +// checks sqlc's generated struct at startup. A column added without an override +// has no stash tag, and the program panics here instead of storing it as +// plaintext. +var sqlcRowPlan = stackencrypt.MustRowPlan[userdb.User](usersPlan) + +// SQLCStore keeps users in Postgres through the queries sqlc generates. +type SQLCStore struct { + db *sql.DB + queries *userdb.Queries + client *stackencrypt.Client +} + +func NewSQLCStore(db *sql.DB, client *stackencrypt.Client) *SQLCStore { + return &SQLCStore{db: db, queries: userdb.New(db), client: client} +} + +func (s *SQLCStore) cipher(tenant string) *stackencrypt.Cipher { + return s.client.Keyset(stackencrypt.KeysetName(tenant)) +} + +func (s *SQLCStore) Create(ctx context.Context, tenant string, user User) error { + row, err := sqlcRowPlan.Encrypt(ctx, s.cipher(tenant), user) + if err != nil { + return fmt.Errorf("encrypt user %d: %w", user.ID, err) + } + // CreateUserParams has the same fields as userdb.User, in the same order, + // so Go converts one to the other. If a change to the query breaks that, + // this line stops compiling. + return s.queries.CreateUser(ctx, userdb.CreateUserParams(row)) +} + +// Import encrypts every user in one ZeroKMS request, then inserts them in one +// transaction. +func (s *SQLCStore) Import(ctx context.Context, tenant string, people []User) error { + rows, err := sqlcRowPlan.EncryptAll(ctx, s.cipher(tenant), people) + if err != nil { + return fmt.Errorf("encrypt %d users: %w", len(people), err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + qtx := s.queries.WithTx(tx) + for _, row := range rows { + if err := qtx.CreateUser(ctx, userdb.CreateUserParams(row)); err != nil { + return fmt.Errorf("insert user %d: %w", row.ID, err) + } + } + return tx.Commit() +} + +func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, error) { + row, err := s.queries.GetUser(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, err + } + return sqlcRowPlan.Decrypt(ctx, s.cipher(tenant), row) +} + +func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { + cipher := s.cipher(tenant) + term, err := emailPlan.Equality(ctx, cipher, email) + if err != nil { + return nil, err + } + rows, err := s.queries.FindUsersByEmail(ctx, term) + if err != nil { + return nil, err + } + return sqlcRowPlan.DecryptAll(ctx, cipher, rows) +} + +func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { + rows, err := s.queries.ListUsers(ctx) + if err != nil { + return nil, err + } + return sqlcRowPlan.DecryptAll(ctx, s.cipher(tenant), rows) +} + +// ChangeEmail rewrites one field. The email field owns three columns, and all +// three change together: a stale email_eq would let FindByEmail match the old +// address. +func (s *SQLCStore) ChangeEmail(ctx context.Context, tenant string, id int64, email string) error { + field, err := emailPlan.Encrypt(ctx, s.cipher(tenant), email) + if err != nil { + return err + } + return s.queries.UpdateUserEmail(ctx, userdb.UpdateUserEmailParams{ + ID: id, + Email: field.Ciphertext, + EmailEq: field.Equality, + EmailMatch: field.Match, + }) +} diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go new file mode 100644 index 000000000..01cfba427 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -0,0 +1,134 @@ +package users + +import ( + "context" + "database/sql" + "fmt" + "slices" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +const ( + columns = `id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes` + insertUser = `INSERT INTO users (` + columns + `) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)` + selectUser = `SELECT ` + columns + ` FROM users` +) + +// SQLStore keeps users in Postgres through database/sql. Each tenant has its +// own keyset; every tenant shares the plans. +type SQLStore struct { + db *sql.DB + client *stackencrypt.Client +} + +func NewSQLStore(db *sql.DB, client *stackencrypt.Client) *SQLStore { + return &SQLStore{db: db, client: client} +} + +func (s *SQLStore) cipher(tenant string) *stackencrypt.Cipher { + return s.client.Keyset(stackencrypt.KeysetName(tenant)) +} + +func (s *SQLStore) Create(ctx context.Context, tenant string, user User) error { + row, err := userRowPlan.Encrypt(ctx, s.cipher(tenant), user) + if err != nil { + return fmt.Errorf("encrypt user %d: %w", user.ID, err) + } + _, err = s.db.ExecContext(ctx, insertUser, args(row)...) + return err +} + +// Import encrypts every user in one ZeroKMS request, then inserts them in one +// transaction. +func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) error { + rows, err := userRowPlan.EncryptAll(ctx, s.cipher(tenant), people) + if err != nil { + return fmt.Errorf("encrypt %d users: %w", len(people), err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, insertUser) + if err != nil { + return err + } + defer stmt.Close() + + for _, row := range rows { + if _, err := stmt.ExecContext(ctx, args(row)...); err != nil { + return fmt.Errorf("insert user %d: %w", row.ID, err) + } + } + return tx.Commit() +} + +func (s *SQLStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { + cipher := s.cipher(tenant) + term, err := emailPlan.Equality(ctx, cipher, email) + if err != nil { + return nil, err + } + rows, err := s.query(ctx, selectUser+` WHERE email_eq = $1`, term) + if err != nil { + return nil, err + } + return userRowPlan.DecryptAll(ctx, cipher, rows) +} + +// OldestFirst returns users aged minAge or over, oldest first. ORE terms +// compare in Go; a range scan inside the database needs EQL's ORE operators. +func (s *SQLStore) OldestFirst(ctx context.Context, tenant string, minAge uint32) ([]User, error) { + cipher := s.cipher(tenant) + floor, err := agePlan.Ore(ctx, cipher, minAge) + if err != nil { + return nil, err + } + rows, err := s.query(ctx, selectUser) + if err != nil { + return nil, err + } + rows = slices.DeleteFunc(rows, func(r UserRow) bool { return r.AgeOre.Compare(floor) < 0 }) + slices.SortFunc(rows, func(a, b UserRow) int { return b.AgeOre.Compare(a.AgeOre) }) + return userRowPlan.DecryptAll(ctx, cipher, rows) +} + +func (s *SQLStore) WithRole(ctx context.Context, tenant, role string) ([]User, error) { + cipher := s.cipher(tenant) + contains, err := attrsPlan.Contains(ctx, cipher, map[string]any{"role": role}) + if err != nil { + return nil, err + } + // Illustrative: the containment predicate is EQL's. + rows, err := s.query(ctx, selectUser+` WHERE attrs @> $1`, contains) + if err != nil { + return nil, err + } + return userRowPlan.DecryptAll(ctx, cipher, rows) +} + +func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]UserRow, error) { + rs, err := s.db.QueryContext(ctx, q, params...) + if err != nil { + return nil, err + } + defer rs.Close() + + var found []UserRow + for rs.Next() { + var r UserRow + if err := rs.Scan(&r.ID, &r.Email, &r.EmailEq, &r.EmailMatch, &r.Age, &r.AgeEq, &r.AgeOre, &r.Attrs, &r.Notes); err != nil { + return nil, err + } + found = append(found, r) + } + return found, rs.Err() +} + +func args(r UserRow) []any { + return []any{r.ID, r.Email, r.EmailEq, r.EmailMatch, r.Age, r.AgeEq, r.AgeOre, r.Attrs, r.Notes} +} From 7bcf79486071ef11578924c34c9cd30fb7bfd508 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 11:25:32 +1100 Subject: [PATCH 02/22] docs(plans): Go binding as typed plans with ctx-first methods Replaces "The Go mirror" with "The Go binding" and brings the intro, decision 2, the other-languages table, sequencing step 5 and the open questions in line with it. The plan states the design; the reasons are here. Typed plans are the design, not an open question. The mirrored chain ended in one Run(ctx) on one builder type, and Go methods cannot take type parameters. So Run could only return `any`: cipher.Encrypt(user).Using(p).Run(ctx) and cipher.Encrypt(users)... share a builder type and differ only at run time. Every caller would assert a type, as probe.(stackencrypt.EqualityTerm) does today. A method on a generic type can use the type's parameter, so RecordPlan[T], RowPlan[T, R] and ValuePlan[T] return a concrete type from every call. Using(p) cannot be overloaded for a one-value plan and a record plan. ValuePlan[T] is both a standalone one-value plan (the Rust age_plan) and one field of a record plan (Field[V]), so queries and one-column updates are typed as well. Calls that could fail silently now fail loudly or do not compile: - A slice meant a batch by reflection, which is ambiguous ([]string is one value or two; []byte is worse). EncryptAll and DecryptAll say so. - Discarding a builder compiles and go vet is silent, so a forgotten .Run(ctx) after .Into(&user) decrypted nothing. Every call returns its result. - TermKind is a uint32: it cannot carry Match or JSON options, and TermKind(42) compiles. Index is an interface only stackencrypt implements. - EncryptIndex(name, first Index, rest ...Index) makes an empty index set a compile error (Dave Cheney's required-first variadic), as `()` not implementing Indexes does in Rust. A policy that computes an empty slice would otherwise drop the index with no error: the #1051 failure. - An EncryptedRecord map lookup with a typo returned a zero value, and the insert wrote NULL. Field(name) returns ErrUnknownField. - An untagged struct field was left out of the record with no error. PlanOf refuses an exported field with no stash tag. - RowPlan[T, R] checks a storage struct (hand-written, a GORM model or an sqlc model) against the plan at init. A column added without an override panics at startup instead of storing plaintext. Go idiom: - ctx is the first parameter and is never stored, as the context package asks. A .Context("users") chain method read as context.Context (r.Context(), the Google API clients' .Context(ctx).Do()), and the package already has a Context type. The context is now an argument to NewPlan, NewValuePlan or Cipher.Encrypt, or the context= tag. - The inline .Fields() chain on Encrypt is gone. It rebuilt and checked the plan on every call; a plan is now a package-level value built once. - Builder methods return a new builder, so two chains from one base cannot share state. A built plan never changes and goroutines share it. - PlanOf returns an error, because a tag can fail to parse, and MustPlanOf panics for package-level variables, as plan.MustPlanFor does. - Errors are a *PlanError wrapping sentinels, for errors.Is and errors.As. An error never holds a plaintext value, because a fail-closed error names a field and a careless %v would print it. Storage: - EncryptedField.Ciphertext was `any`, which is not a driver.Valuer. Ciphertext is one concrete type that implements driver.Valuer and sql.Scanner. A scalar column can hold any of the four Sealed leaf kinds, so one storage type replaces them. - Decrypter keeps both decrypt behaviours the binding has today: a *Client opens a leaf under any of its keysets, and a *Cipher refuses a leaf from another keyset with ErrForeignKeyset. - The tags used `plain` for a field left out of the record, beside a Passthrough verb that carries a field unsealed. The tags now use the verbs: passthrough, and `-` for Omit. - The policy package's Plaintext decision becomes an Omit field, so Bind accepts the struct under the fail-closed rule. A batch across plans stays an open question. The draft spelled it Prepare, which reads as database/sql's PrepareContext; the question now records the typed-handle shape instead. The new text follows ISO 24495-1 and ASD-STE100, one sentence to a line, and passes slipstream's guide-language, guide-length and line-break checks: 0 findings, 93 sentences, the longest 24 words, a deviation of 5.06. Paragraphs this change does not touch keep their wrapping. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 259 ++++++++++++++++++++------ 1 file changed, 199 insertions(+), 60 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 3ab8a74be..966756b46 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -40,8 +40,8 @@ the tagged encoding). After this work there is one front end, a **plan builder**, with three authors: a person writing a chain, the derive writing it from attributes, and -the FFI writing it from data. The Go binding mirrors the same chain. The -combinators stay public as the extension point. Everything a Rust caller, the +the FFI writing it from data. The Go binding uses the same plans in Go's own +call shape. The combinators stay public as the extension point. Everything a Rust caller, the derive, Go and the guest produce for the same declaration is the same bytes by construction, because it is the same code. @@ -101,8 +101,8 @@ split was the problem, not the function names: await nothing has touched a key. 2. **The context slot is named `context`.** It is the honest name for what is supplied. `under` was rejected (borrowed from `Encryption::under`, and - opaque to a reader). Go accepts the clash with `context.Context`; every - Go call's first argument is already `ctx`. + opaque to a reader). The Go binding has no `context` method; see "The Go + binding". 3. **Field-by-field is a modifier, `.fields()`, not a second verb.** `columns` was rejected as database-centric; the SDKs are not only for databases. `fields` is the derive docs' own phrase ("field by field") and @@ -501,63 +501,199 @@ them on the struct and lowers them through `spec()`. The data form is what crosses the FFI and what a saved plan in Go holds; the Rust side never loses the type. -## The Go mirror +## The Go binding -> #1070 replaces this chain with a generator, `stashgen`, that writes each -> type's encrypted type and plan as data; this section is the first sketch. +Go uses the same plans as Rust, in Go's own call shape. +A plan is built and checked once, then held in a package-level variable. +Every operation is a method on a typed plan. +It takes `ctx` first and the cipher second, and it returns a concrete type. +[The Go examples](2026-10-04-plan-builder/README.md) show each part below as a complete program. -Go mirrors the chain with one difference: no `await`, so the finalizer is an -explicit `Run(ctx)`. One verb, `Encrypt`, for a tree and for a plan alike. -The plan is a value, because in Go it is produced by the policy package -(`plan.PlanFor`) and by struct tags, and because it is the data the guest -receives. +```go +type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + Age uint32 `stash:"age,encrypt,index=equality;ore"` + Notes string `stash:"notes,encrypt"` +} + +var ( + usersPlan = stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]()) + emailPlan = stackencrypt.MustField[string](usersPlan, "email") +) + +cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) + +record, err := usersPlan.Encrypt(ctx, cipher, alice) // EncryptedRecord +records, err := usersPlan.EncryptAll(ctx, cipher, people) // []EncryptedRecord, one ZeroKMS request +user, err := usersPlan.Decrypt(ctx, client, record) // User +term, err := emailPlan.Equality(ctx, cipher, "bob@example.com") // EqualityTerm +``` + +### Plan types + +Four types hold a plan: + +- `Plan` is the untyped data form. + `NewPlan`, `PlanOf` and the policy package make one, and the guest receives one. +- `RecordPlan[T]` is a `Plan` that `Bind[T]` binds to the struct type `T`. +- `ValuePlan[T]` is a plan for one value of type `T`. + `NewValuePlan[T]` makes one, and `Field[V]` takes one field of a record plan as one. +- `RowPlan[T, R]` is a record plan whose output is the storage struct `R`. + `NewRowPlan[R]` makes one. + +Each constructor returns an error, and each has a `Must` form that panics, for package-level variables. +A plan does not change after it is built, and any number of goroutines can use it at the same time. +See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`blocklist/blocklist.go`](2026-10-04-plan-builder/blocklist/blocklist.go). + +### Plans from struct tags + +`PlanOf[T]` reads the `stash` tag on each field of `T`: + +| Tag | Field verb | +|---|---| +| `` _ struct{} `stash:"context=users"` `` | the plan's context | +| `stash:"notes,encrypt"` | `Encrypt` | +| `stash:"email,encrypt,index=equality;match"` | `EncryptIndex` | +| `stash:"attrs,index=json"` | `Index` | +| `stash:"id,passthrough"` | `Passthrough` | +| `stash:"-"` | `Omit` | + +The index names are `equality`, `match`, `ore`, `ope` and `json`. +`PlanOf` refuses an exported field with no `stash` tag. +It ignores unexported fields. +The fields of an embedded struct are fields of the outer struct. + +### The builder + +`NewPlan` makes the same kind of plan without tags: ```go -// One value, one tree. The full chain: plan built and run in one call. -ct, err := cipher.Encrypt(doc).Context("documents/v2/body").Run(ctx) -row, err := cipher.Encrypt(user).Context("users").Fields(). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match). - EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). - Index("attrs", stackencrypt.Json()). - Encrypt("notes"). - Passthrough("id"). - Run(ctx) // Build()'s validation happens here, same errors - -// The same chain without the value: a plan. -usersPlan, err := stackencrypt.PlanContext("users").Fields(). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match). - EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). - Index("attrs", stackencrypt.Json()). - Encrypt("notes"). - Passthrough("id"). - Build() - -// Struct tags are Go's derive. They are never applied silently: the plan is -// named, and PlanOf reads and caches the tags, the Go spelling of -// EncryptedUser::plan(). A chain with no Using is the tree, as it reads. -usersPlan := stackencrypt.PlanOf[User]() - -row, err := cipher.Encrypt(user).Using(usersPlan).Run(ctx) -rows, err := cipher.Encrypt(users).Using(usersPlan).Run(ctx) -err = cipher.Decrypt(row).Using(usersPlan).Into(&user).Run(ctx) -emailPlan, err := usersPlan.Field("email") -q, err := cipher.Query("bob@example.com").Using(emailPlan).Equality().Run(ctx) +contactsPlan, err := stackencrypt.NewPlan("contacts"). + Passthrough("id"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("phone_number", stackencrypt.Equality). + Omit("internal"). + Build() ``` -- Indexes are the existing `TermKind` values (data); Go has no way to make - `Match` on an integer a compile error, so it is a `Build()` error, as today. -- `Encrypt` takes a `Context` where today it takes `aad []byte`; raw bytes - stay possible as `NewContext(bytes)`, the same bytes part Rust accepts. -- The output types are what the binding has: `EncryptedRecord` and - `EncryptedField`. A typed `Plan[T]` is superseded by #1070's generated - types. -- Removed: `Encrypt(…, aad []byte)`, `EncryptElement`, `DecryptElement`, - `EncryptRecord(s)`, `DecryptRecord(s)`, `Term`, `RecordOption`, `WithPlan`, - `PlanFromTags` (replaced by `PlanOf[T]`) and the "zero Plan means the - struct's tags" rule. The binding has never been released, so they are - removed, not deprecated. -- Mixed batches: `Prepare` on each chain and one `stackencrypt.Run(ctx, p1, - p2)`, mirroring `all(..)`. Sugar, not the entry point. +A field name is the record key, which is the column name in a database. +`EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. +Each method returns a new builder and does not change the builder it is called on. +`Build` checks the whole-plan rules and returns a `Plan`. +See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). + +`Bind[T]` matches each plan field to a field of `T`. +It uses the `stash` tag name first, then the Go field name that a policy fact records, then the Go field name in snake_case. +It refuses a plan field with no match, and a field of `T` that the plan does not name. +It also refuses an index that does not apply to the field's Go type, such as `Match` on an integer. + +### The policy package + +`plan.PlanFor` makes a `Plan` from facts and a policy. +`plan.EQL` takes `stackencrypt.Index` values. +A field that the policy stores as plaintext is an `Omit` field in the plan, so `Bind` accepts the struct. +See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individuals.go). + +### Indexes + +`Index` is an interface that only `stackencrypt` implements. +`Equality`, `Ore` and `Ope` are values. +`Match(opts ...MatchOption)` and `JSON(opts ...JSONOption)` are constructors that take the index's options. + +### Operations + +| Method | Returns | +|---|---| +| `RecordPlan[T].Encrypt(ctx, c *Cipher, v T, opts ...Option)` | `EncryptedRecord` | +| `RecordPlan[T].EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]EncryptedRecord` | +| `RecordPlan[T].Decrypt(ctx, d Decrypter, r EncryptedRecord, opts ...Option)` | `T` | +| `RecordPlan[T].DecryptAll(ctx, d Decrypter, rs []EncryptedRecord, opts ...Option)` | `[]T` | +| `RowPlan[T, R]`: the same four methods | `R` in place of `EncryptedRecord` | +| `ValuePlan[T]`: the same four methods | `EncryptedField` in place of `EncryptedRecord` | +| `ValuePlan[T].Equality(ctx, c *Cipher, v T, opts ...Option)` | `EqualityTerm` | +| `ValuePlan[T].Match`, `ValuePlan[T].Ore`, `ValuePlan[T].Ope` | `MatchTerm`, `OreTerm`, `OpeTerm` | +| `ValuePlan[T].Contains(ctx, c *Cipher, v T, opts ...Option)` | `JSONQuery` | +| `ValuePlan[T].Selector(ctx, c *Cipher, path JSONPath, opts ...Option)` | `JSONSelector` | +| `ValuePlan[T].EqualAt(ctx, c *Cipher, path JSONPath, v any, opts ...Option)` | `JSONQuery` | +| `Cipher.Encrypt(ctx, v any, c Context, opts ...Option)` | `Ciphertext`, one tree | +| `DecryptValue[T](ctx, d Decrypter, ct Ciphertext, c Context, opts ...Option)` | `T` | + +Every method also returns an `error`. +`EncryptAll` and `DecryptAll` send one ZeroKMS request for the whole slice. +A query method for an index that the field does not declare returns `ErrIndexNotDeclared`. +`NewContext(bytes)` makes a `Context` from raw bytes, the same bytes part that Rust accepts. +See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go) and [`documents/documents.go`](2026-10-04-plan-builder/documents/documents.go). + +`*Client` and `*Cipher` both implement `Decrypter`. +A `*Client` decrypts each leaf under the keyset that sealed it. +A `*Cipher` also refuses a leaf from another keyset, with `ErrForeignKeyset`. + +The cipher holds the keyset: `client.Keyset(stackencrypt.KeysetName("tenant-42"))`. +No call takes a keyset option. + +`ExtendContext(parts ...any)` is the one `Option`. +It extends the context of every field in the plan. +The write, the query and the read must pass the same parts. +See [`users/extend.go`](2026-10-04-plan-builder/users/extend.go). + +### Output types + +`EncryptedRecord.Field(name)` returns the named field's `EncryptedField`. +For a name that the plan does not have, it returns `ErrUnknownField` and never a zero value. +`EncryptedField` holds `Ciphertext`, `Equality`, `Match`, `Ore`, `Ope` and `JSON`. +An output that the plan does not declare is nil. + +`Ciphertext`, each term type, `JSONDocument`, `JSONQuery` and `JSONSelector` implement `driver.Valuer`. +`Ciphertext`, each term type and `JSONDocument` also implement `sql.Scanner`. + +`NewRowPlan[R]` reads the `stash` tags of `R`. +`stash:"email"` holds the field's ciphertext, and `stash:"email,equality"` holds one of its terms. +`NewRowPlan` refuses a field of `R` with no tag, and a plan output with no field in `R`. + +### Errors + +`Build`, `Bind`, `NewRowPlan`, `Field` and every operation return a `*PlanError`. +A `*PlanError` names the field and wraps one rule: + +- `ErrUnknownField`: the plan has no field with that name. +- `ErrMissingField`: a plan field is not in the value. +- `ErrUnplannedField`: the value has a field that the plan does not name. +- `ErrIndexNotDeclared`: the field does not declare that index. +- `ErrIndexType`: the index does not apply to the field's type. + +Use `errors.As` and `errors.Is` to read it. +No error holds a plaintext value. + +### Databases and ORMs + +- **database/sql:** the `R` of a `RowPlan` is the row struct. + Pass its fields to `ExecContext`, and `Scan` into them. + See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go). +- **GORM:** the store encrypts and decrypts outside GORM, and `R` is the GORM model. + See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go). +- **sqlc:** column overrides set each column's `go_type`, and `go_struct_tag` puts the `stash` tag on the generated struct. + The generated model is `R`, and an `INSERT` params struct converts from it. + See [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go) and [`sqlc/sqlc.yaml`](2026-10-04-plan-builder/sqlc/sqlc.yaml). +- **sqlc with EQL domain columns:** sqlc reads a file that declares the domains in place of the EQL install bundle, which it cannot parse. + See [the three rules for EQL domain columns](2026-10-04-plan-builder/README.md#use-sqlc-with-eql-domain-columns). + +### Removed + +The binding has never been released, so these are removed, not deprecated: + +- `Cipher.Encrypt(ctx, v, aad []byte)`: `Cipher.Encrypt` takes a `Context`. +- `Cipher.Decrypt` and `Client.Decrypt`: `DecryptValue[T]` replaces them. +- `EncryptElement` and `DecryptElement`. +- `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`: `RecordPlan[T]` replaces them. +- `Cipher.Term`: the query methods of `ValuePlan[T]` replace it. +- `RecordOption` and `WithPlan`. +- `TermKind`: `Index` replaces it. +- `FieldPlan`, `NewPlan(fields ...FieldPlan)` and `Plan.Validate`: the builder and `Bind[T]` replace them. +- `PlanFromTags`: `PlanOf[T]` replaces it. +- The rule that a zero `Plan` means the struct's tags. +- `Sealed`, `SealedNone`, `SealedEmptyMap` and `SealedEmptySeq` as storage types: `Ciphertext` replaces them. ### The guest @@ -595,11 +731,11 @@ source. | Concern | Rust | Go | Node/TS | Python | C# | |---|---|---|---|---|---| | Shell | in-process | WASI guest (wazero) | napi; the guest for `wasm-inline` on the edge | PyO3 | P/Invoke to a cdylib | -| Finalizer | `.await` | `Run(ctx)` | `await` (thenable chain) | `await` plus sync `.run()` | `await` (`GetAwaiter`) or `RunAsync(ct)` | -| Plan from a type | derive | tags, `PlanOf[T]()` | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | -| Index applies to type | compile time | `Build()` | partly via conditional types | build | partly via constraints | -| Typed output | `Encrypted`, derived struct | `EncryptedRecord`; `Plan[T]` later | inferred from the plan | dict or the dataclass | `Plan`, `Task` | -| Query form | source type | source struct | overloads or union | runtime type | overloads | +| Finalizer | `.await` | none: each method runs when called, `ctx` first | `await` (thenable chain) | `await` plus sync `.run()` | `await` (`GetAwaiter`) or `RunAsync(ct)` | +| Plan from a type | derive | tags, `PlanOf[T]()` then `Bind[T]` | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | +| Index applies to type | compile time | `Bind[T]` | partly via conditional types | build | partly via constraints | +| Typed output | `Encrypted`, derived struct | `RecordPlan[T]`, `RowPlan[T, R]`, `ValuePlan[T]` | inferred from the plan | dict or the dataclass | `Plan`, `Task` | +| Query form | source type | one method for each form | overloads or union | runtime type | overloads | The fail-closed `build()` checks are the floor everywhere; compile-time checks are a bonus where the language has them. @@ -751,6 +887,9 @@ Then: ## Open questions +- **A batch across plans in Go.** + `EncryptAll` and `DecryptAll` batch the values of one plan. + The Go form of `all(..)` gives a typed handle for each operation, and the Go PR settles its spelling. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. From 56407418b56b2ef6d12e16deacbfb0fee6eda757 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 12:41:11 +1100 Subject: [PATCH 03/22] docs(plans): generate the encrypted Go type with stashgen Makes a generated type the main Go path for a struct with stash tags. The plan states the design; the reasons are here. The goal is a concrete output type that matches the input type, checked by the compiler. Go has three ways to write that type's shape (generate it, write it by hand, or hold both states in one type) and two ways to connect it to the plaintext type (generics, interfaces). Neither generics nor interfaces can write the shape: Go generics substitute types and cannot derive one struct from another, and an interface describes methods, not fields. Why generate: the type and the plan come from the same tags, so they cannot disagree, and every check moves to compile time. A hand-written type is checked against the plan only at package init. One type that holds plaintext before Encrypt and ciphertext after was rejected: a forgotten Encrypt is found only when the driver reads the value, and a log line prints whatever the field holds at that moment. Why each field holds only its declared outputs: EncryptedField has a slot for every index type, and a slot the plan does not declare is nil. Reading it compiled and wrote NULL. EncryptedUserEmail has no Ore field, so the mistake does not compile. UserFields closes the same gap for queries: UserFields.Email has no Ore method. Why the Planned interface: a StashPlan method on User and on EncryptedUser lets stackencrypt.Encrypt and stackencrypt.Decrypt infer the result type from the value. The caller names no plan, so a value cannot be paired with the wrong one. Go 1.26 infers the type through the method for a value and for a slice; the examples compile with it. Why go generate, and a Go command: a Go module that needs Node to build is a poor fit, so stashgen lives in the Go module and runs with `go tool`. This is the easyjson, msgp and gorm.io/gen direction (a Go type is the source of truth), not sqlc's (SQL is). Why RowPlan stays: an sqlc model or a GORM model is owned by another tool, so stashgen cannot write it. NewRowPlan now also accepts a struct field that holds all of one field's outputs, which is the layout the generated type uses, so the generated file needs no second mechanism. RowPlan.Record() lets the GORM and sqlc stores build their row plans from the generated plan. A protobuf message cannot carry tags; a protoc plugin front end is recorded as an open question. stashgen does not exist, so users/user_stash.go is written by hand as the file it will write. The Go files type-check (`go vet ./...`) against the uncommitted stub of the proposed API, and gofmt reports nothing. The amended text passes slipstream's language, length and line-break checks with 0 findings (117 sentences, the longest 24 words). Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 68 +++++++--- docs/plans/2026-10-04-plan-builder/README.md | 13 +- .../2026-10-04-plan-builder/users/extend.go | 6 +- .../users/gormstore.go | 25 +++- .../2026-10-04-plan-builder/users/model.go | 35 +---- .../users/sqlcstore.go | 6 +- .../2026-10-04-plan-builder/users/sqlstore.go | 52 ++++---- .../users/user_stash.go | 122 ++++++++++++++++++ 8 files changed, 240 insertions(+), 87 deletions(-) create mode 100644 docs/plans/2026-10-04-plan-builder/users/user_stash.go diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 966756b46..0642b35d9 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -505,11 +505,12 @@ the type. Go uses the same plans as Rust, in Go's own call shape. A plan is built and checked once, then held in a package-level variable. -Every operation is a method on a typed plan. -It takes `ctx` first and the cipher second, and it returns a concrete type. +Every operation takes `ctx` first and the cipher second, and it returns a concrete type. +For a struct with `stash` tags, a generator writes the encrypted type, so the compiler checks each field and each index. [The Go examples](2026-10-04-plan-builder/README.md) show each part below as a complete program. ```go +//go:generate go tool stashgen -type User type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` @@ -518,17 +519,15 @@ type User struct { Notes string `stash:"notes,encrypt"` } -var ( - usersPlan = stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]()) - emailPlan = stackencrypt.MustField[string](usersPlan, "email") -) - cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) -record, err := usersPlan.Encrypt(ctx, cipher, alice) // EncryptedRecord -records, err := usersPlan.EncryptAll(ctx, cipher, people) // []EncryptedRecord, one ZeroKMS request -user, err := usersPlan.Decrypt(ctx, client, record) // User -term, err := emailPlan.Equality(ctx, cipher, "bob@example.com") // EqualityTerm +enc, err := stackencrypt.Encrypt(ctx, cipher, alice) // EncryptedUser +all, err := stackencrypt.EncryptAll(ctx, cipher, people) // []EncryptedUser, one ZeroKMS request +user, err := stackencrypt.Decrypt(ctx, client, enc) // User +term, err := UserFields.Email.Equality(ctx, cipher, "bob@example.com") // EqualityTerm + +_ = enc.Email.Equality // EqualityTerm +_ = enc.Email.Ore // does not compile: email declares no ORE index ``` ### Plan types @@ -545,7 +544,7 @@ Four types hold a plan: Each constructor returns an error, and each has a `Must` form that panics, for package-level variables. A plan does not change after it is built, and any number of goroutines can use it at the same time. -See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`blocklist/blocklist.go`](2026-10-04-plan-builder/blocklist/blocklist.go). +See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go) and [`blocklist/blocklist.go`](2026-10-04-plan-builder/blocklist/blocklist.go). ### Plans from struct tags @@ -565,6 +564,33 @@ The index names are `equality`, `match`, `ore`, `ope` and `json`. It ignores unexported fields. The fields of an embedded struct are fields of the outer struct. +### Generated types + +`stashgen` is a Go command in the module, and `go generate` runs it. +It reads the `stash` tags of one struct and writes one file beside it. +For `User`, the file `user_stash.go` holds: + +- `EncryptedUser`, with one field for each field of `User` that the plan stores. + A field with one output has that output's type, such as `Ciphertext`. + A field with more outputs has its own struct, such as `EncryptedUserEmail`, with one field for each output. +- The plan, built once at package init. +- A `StashPlan` method on `User` and on `EncryptedUser`. + The method gives each type the `Planned` interface, so `stackencrypt.Encrypt` and `stackencrypt.Decrypt` find the plan and the result type from the value. +- `UserFields`, with one entry for each sealed field. + An entry encrypts one value of that field, and it has a query method only for an index the field declares. + +The compiler then checks every use. +The fields `enc.Email.Ore` and `UserFields.Email.Ore` do not compile, because `email` declares no ORE index. +A call to `stackencrypt.Encrypt` does not compile for a type that has no `StashPlan` method. + +The generated file is committed. +CI runs `go generate` and fails when the result differs from the committed file. +A stale file that still compiles panics at package init, because the plan checks `EncryptedUser` against the tags. +See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_stash.go`](2026-10-04-plan-builder/users/user_stash.go). + +A struct that another tool generates cannot use `stashgen`. +For an sqlc model or a GORM model, `NewRowPlan[R]` checks the struct at package init. + ### The builder `NewPlan` makes the same kind of plan without tags: @@ -606,6 +632,10 @@ See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individua | Method | Returns | |---|---| +| `Encrypt(ctx, c *Cipher, v T, opts ...Option)`, for a `Planned` type | the generated type `R` | +| `EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]R` | +| `Decrypt(ctx, d Decrypter, r R, opts ...Option)` | `T` | +| `DecryptAll(ctx, d Decrypter, rs []R, opts ...Option)` | `[]T` | | `RecordPlan[T].Encrypt(ctx, c *Cipher, v T, opts ...Option)` | `EncryptedRecord` | | `RecordPlan[T].EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]EncryptedRecord` | | `RecordPlan[T].Decrypt(ctx, d Decrypter, r EncryptedRecord, opts ...Option)` | `T` | @@ -650,7 +680,9 @@ An output that the plan does not declare is nil. `NewRowPlan[R]` reads the `stash` tags of `R`. `stash:"email"` holds the field's ciphertext, and `stash:"email,equality"` holds one of its terms. +A struct field with `stash:"email"` holds all the outputs of `email`, in fields named `Ciphertext`, `Equality`, `Match`, `Ore`, `Ope` and `JSON`. `NewRowPlan` refuses a field of `R` with no tag, and a plan output with no field in `R`. +`RowPlan[T, R].Record()` returns the record plan, so one plan can have more than one `R`. ### Errors @@ -668,10 +700,9 @@ No error holds a plaintext value. ### Databases and ORMs -- **database/sql:** the `R` of a `RowPlan` is the row struct. - Pass its fields to `ExecContext`, and `Scan` into them. +- **database/sql:** pass the fields of the generated type to `ExecContext`, and `Scan` into them. See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go). -- **GORM:** the store encrypts and decrypts outside GORM, and `R` is the GORM model. +- **GORM:** the store encrypts and decrypts outside GORM, and the `R` of a `RowPlan` is the GORM model. See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go). - **sqlc:** column overrides set each column's `go_type`, and `go_struct_tag` puts the `stash` tag on the generated struct. The generated model is `R`, and an `INSERT` params struct converts from it. @@ -732,9 +763,9 @@ source. |---|---|---|---|---|---| | Shell | in-process | WASI guest (wazero) | napi; the guest for `wasm-inline` on the edge | PyO3 | P/Invoke to a cdylib | | Finalizer | `.await` | none: each method runs when called, `ctx` first | `await` (thenable chain) | `await` plus sync `.run()` | `await` (`GetAwaiter`) or `RunAsync(ct)` | -| Plan from a type | derive | tags, `PlanOf[T]()` then `Bind[T]` | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | +| Plan from a type | derive | tags and `stashgen`; `PlanOf[T]()` then `Bind[T]` with no generator | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | | Index applies to type | compile time | `Bind[T]` | partly via conditional types | build | partly via constraints | -| Typed output | `Encrypted`, derived struct | `RecordPlan[T]`, `RowPlan[T, R]`, `ValuePlan[T]` | inferred from the plan | dict or the dataclass | `Plan`, `Task` | +| Typed output | `Encrypted`, derived struct | a generated struct (`stashgen`); `RowPlan[T, R]` for a struct another tool owns | inferred from the plan | dict or the dataclass | `Plan`, `Task` | | Query form | source type | one method for each form | overloads or union | runtime type | overloads | The fail-closed `build()` checks are the floor everywhere; compile-time checks @@ -890,6 +921,9 @@ Then: - **A batch across plans in Go.** `EncryptAll` and `DecryptAll` batch the values of one plan. The Go form of `all(..)` gives a typed handle for each operation, and the Go PR settles its spelling. +- **A protobuf front end for `stashgen`.** + A protobuf message cannot carry `stash` tags. + A protoc plugin reads field options and writes the same generated file, and the protobuf source PR settles it. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index eac4a66bf..ec4605a07 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -9,10 +9,11 @@ The module path is `example.com/app`. | File | What it shows | |---|---| | [`main.go`](main.go) | A client, one keyset for each tenant, and calls into every package below | -| [`users/model.go`](users/model.go) | A plan in struct tags, a storage struct, and the plans held in package-level variables | -| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql`: insert, batch insert in a transaction, equality search, ORE ordering and JSON containment | -| [`users/gormstore.go`](users/gormstore.go) | GORM, with the storage struct as the model | -| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, including an update of one field and its terms | +| [`users/model.go`](users/model.go) | A plan in struct tags, and the `go:generate` line that runs `stashgen` | +| [`users/user_stash.go`](users/user_stash.go) | The file that `stashgen` writes: the encrypted type, the plan, and the typed fields | +| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql` with the generated type: insert, batch insert in a transaction, equality search, ORE ordering and JSON containment | +| [`users/gormstore.go`](users/gormstore.go) | GORM, with a hand-written model that a row plan checks at package init | +| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with the sqlc model in a row plan, and an update of one field and its terms | | [`users/extend.go`](users/extend.go) | A context extension on the write, the query and the read | | [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | | [`contacts/contacts.go`](contacts/contacts.go) | A plan built by hand for a type with no tags | @@ -21,6 +22,10 @@ The module path is `example.com/app`. | [`documents/documents.go`](documents/documents.go) | One value sealed as one tree, decrypted with the client | | [`eql-sqlc/`](eql-sqlc/) | sqlc with EQL v3 domain columns, which generates [`internal/eqldb/`](internal/eqldb/) | +## Generate the encrypted type + +`stashgen` does not exist yet, so [`users/user_stash.go`](users/user_stash.go) is written by hand as the file it will write. + ## Generate the sqlc packages The two sqlc packages were generated with sqlc v1.31.1. diff --git a/docs/plans/2026-10-04-plan-builder/users/extend.go b/docs/plans/2026-10-04-plan-builder/users/extend.go index 0ac6589c0..11a6ab116 100644 --- a/docs/plans/2026-10-04-plan-builder/users/extend.go +++ b/docs/plans/2026-10-04-plan-builder/users/extend.go @@ -12,12 +12,12 @@ import ( func RoundTripForTenant(ctx context.Context, cipher *stackencrypt.Cipher, part string, user User) (User, error) { extend := stackencrypt.ExtendContext(part) - record, err := usersPlan.Encrypt(ctx, cipher, user, extend) + enc, err := stackencrypt.Encrypt(ctx, cipher, user, extend) if err != nil { return User{}, err } - if _, err := emailPlan.Equality(ctx, cipher, user.Email, extend); err != nil { + if _, err := UserFields.Email.Equality(ctx, cipher, user.Email, extend); err != nil { return User{}, err } - return usersPlan.Decrypt(ctx, cipher, record, extend) + return stackencrypt.Decrypt(ctx, cipher, enc, extend) } diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index 38e3959e8..2047395f8 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -7,6 +7,25 @@ import ( "gorm.io/gorm" ) +// UserRow is the GORM model: one field for each column. GORM's default naming +// maps EmailEq to email_eq. It is written by hand, so the row plan checks it +// against the plan at package init. +type UserRow struct { + ID int64 `stash:"id"` + Email stackencrypt.Ciphertext `stash:"email"` + EmailEq stackencrypt.EqualityTerm `stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `stash:"email,match"` + Age stackencrypt.Ciphertext `stash:"age"` + AgeEq stackencrypt.EqualityTerm `stash:"age,equality"` + AgeOre stackencrypt.OreTerm `stash:"age,ore"` + Attrs stackencrypt.JSONDocument `stash:"attrs,json"` + Notes stackencrypt.Ciphertext `stash:"notes"` +} + +func (UserRow) TableName() string { return "users" } + +var gormRowPlan = stackencrypt.MustRowPlan[UserRow](userPlan.Record()) + // GormStore encrypts before GORM sees a value. driver.Valuer gets no // context.Context and runs one field at a time, so it cannot batch a ZeroKMS // request or stop when the request is cancelled. An AfterFind hook works too, @@ -22,7 +41,7 @@ func NewGormStore(db *gorm.DB, client *stackencrypt.Client) *GormStore { func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) error { cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - rows, err := userRowPlan.EncryptAll(ctx, cipher, people) + rows, err := gormRowPlan.EncryptAll(ctx, cipher, people) if err != nil { return err } @@ -31,7 +50,7 @@ func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) e func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - term, err := emailPlan.Equality(ctx, cipher, email) + term, err := UserFields.Email.Equality(ctx, cipher, email) if err != nil { return nil, err } @@ -39,5 +58,5 @@ func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]Us if err := s.db.WithContext(ctx).Where("email_eq = ?", term).Find(&rows).Error; err != nil { return nil, err } - return userRowPlan.DecryptAll(ctx, cipher, rows) + return gormRowPlan.DecryptAll(ctx, cipher, rows) } diff --git a/docs/plans/2026-10-04-plan-builder/users/model.go b/docs/plans/2026-10-04-plan-builder/users/model.go index e8de82243..aeb6c79e1 100644 --- a/docs/plans/2026-10-04-plan-builder/users/model.go +++ b/docs/plans/2026-10-04-plan-builder/users/model.go @@ -1,10 +1,9 @@ package users -import "github.com/cipherstash/stack/languages/golang/stackencrypt" +//go:generate go tool stashgen -type User -// User is the plaintext a caller works with. Its tags are the plan. PlanOf -// refuses an exported field with no stash tag, so a new field cannot reach the -// database unencrypted by accident. +// User's tags are the plan; stashgen writes user_stash.go from them. An +// exported field with no stash tag is refused, never stored unencrypted. type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` @@ -14,31 +13,3 @@ type User struct { Notes string `stash:"notes,encrypt"` Internal string `stash:"-"` } - -// UserRow is the users table as stored. Every field is a driver.Valuer and an -// sql.Scanner. GORM's default naming maps EmailEq to email_eq; sqlx and scany -// read the db tags. -type UserRow struct { - ID int64 `db:"id" stash:"id"` - Email stackencrypt.Ciphertext `db:"email" stash:"email"` - EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` - Age stackencrypt.Ciphertext `db:"age" stash:"age"` - AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` - AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` - Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` - Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` -} - -func (UserRow) TableName() string { return "users" } - -// The plans are built and checked at package init. A bad tag, or a UserRow -// that does not cover every output of the plan, panics at startup instead of -// on the first request. -var ( - usersPlan = stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]()) - userRowPlan = stackencrypt.MustRowPlan[UserRow](usersPlan) - emailPlan = stackencrypt.MustField[string](usersPlan, "email") - agePlan = stackencrypt.MustField[uint32](usersPlan, "age") - attrsPlan = stackencrypt.MustField[map[string]any](usersPlan, "attrs") -) diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index e3b544f97..b19291092 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -16,7 +16,7 @@ var ErrNotFound = errors.New("users: not found") // checks sqlc's generated struct at startup. A column added without an override // has no stash tag, and the program panics here instead of storing it as // plaintext. -var sqlcRowPlan = stackencrypt.MustRowPlan[userdb.User](usersPlan) +var sqlcRowPlan = stackencrypt.MustRowPlan[userdb.User](userPlan.Record()) // SQLCStore keeps users in Postgres through the queries sqlc generates. type SQLCStore struct { @@ -80,7 +80,7 @@ func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, err func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { cipher := s.cipher(tenant) - term, err := emailPlan.Equality(ctx, cipher, email) + term, err := UserFields.Email.Equality(ctx, cipher, email) if err != nil { return nil, err } @@ -103,7 +103,7 @@ func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { // three change together: a stale email_eq would let FindByEmail match the old // address. func (s *SQLCStore) ChangeEmail(ctx context.Context, tenant string, id int64, email string) error { - field, err := emailPlan.Encrypt(ctx, s.cipher(tenant), email) + field, err := UserFields.Email.Encrypt(ctx, s.cipher(tenant), email) if err != nil { return err } diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go index 01cfba427..7bca19b49 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -16,7 +16,7 @@ const ( ) // SQLStore keeps users in Postgres through database/sql. Each tenant has its -// own keyset; every tenant shares the plans. +// own keyset; every tenant shares the plan. type SQLStore struct { db *sql.DB client *stackencrypt.Client @@ -31,18 +31,18 @@ func (s *SQLStore) cipher(tenant string) *stackencrypt.Cipher { } func (s *SQLStore) Create(ctx context.Context, tenant string, user User) error { - row, err := userRowPlan.Encrypt(ctx, s.cipher(tenant), user) + enc, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), user) if err != nil { return fmt.Errorf("encrypt user %d: %w", user.ID, err) } - _, err = s.db.ExecContext(ctx, insertUser, args(row)...) + _, err = s.db.ExecContext(ctx, insertUser, args(enc)...) return err } // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) error { - rows, err := userRowPlan.EncryptAll(ctx, s.cipher(tenant), people) + encrypted, err := stackencrypt.EncryptAll(ctx, s.cipher(tenant), people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) } @@ -59,9 +59,9 @@ func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) err } defer stmt.Close() - for _, row := range rows { - if _, err := stmt.ExecContext(ctx, args(row)...); err != nil { - return fmt.Errorf("insert user %d: %w", row.ID, err) + for _, enc := range encrypted { + if _, err := stmt.ExecContext(ctx, args(enc)...); err != nil { + return fmt.Errorf("insert user %d: %w", enc.ID, err) } } return tx.Commit() @@ -69,66 +69,68 @@ func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) err func (s *SQLStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { cipher := s.cipher(tenant) - term, err := emailPlan.Equality(ctx, cipher, email) + term, err := UserFields.Email.Equality(ctx, cipher, email) if err != nil { return nil, err } - rows, err := s.query(ctx, selectUser+` WHERE email_eq = $1`, term) + encrypted, err := s.query(ctx, selectUser+` WHERE email_eq = $1`, term) if err != nil { return nil, err } - return userRowPlan.DecryptAll(ctx, cipher, rows) + return stackencrypt.DecryptAll(ctx, cipher, encrypted) } // OldestFirst returns users aged minAge or over, oldest first. ORE terms // compare in Go; a range scan inside the database needs EQL's ORE operators. func (s *SQLStore) OldestFirst(ctx context.Context, tenant string, minAge uint32) ([]User, error) { cipher := s.cipher(tenant) - floor, err := agePlan.Ore(ctx, cipher, minAge) + floor, err := UserFields.Age.Ore(ctx, cipher, minAge) if err != nil { return nil, err } - rows, err := s.query(ctx, selectUser) + encrypted, err := s.query(ctx, selectUser) if err != nil { return nil, err } - rows = slices.DeleteFunc(rows, func(r UserRow) bool { return r.AgeOre.Compare(floor) < 0 }) - slices.SortFunc(rows, func(a, b UserRow) int { return b.AgeOre.Compare(a.AgeOre) }) - return userRowPlan.DecryptAll(ctx, cipher, rows) + encrypted = slices.DeleteFunc(encrypted, func(e EncryptedUser) bool { return e.Age.Ore.Compare(floor) < 0 }) + slices.SortFunc(encrypted, func(a, b EncryptedUser) int { return b.Age.Ore.Compare(a.Age.Ore) }) + return stackencrypt.DecryptAll(ctx, cipher, encrypted) } func (s *SQLStore) WithRole(ctx context.Context, tenant, role string) ([]User, error) { cipher := s.cipher(tenant) - contains, err := attrsPlan.Contains(ctx, cipher, map[string]any{"role": role}) + contains, err := UserFields.Attrs.Contains(ctx, cipher, map[string]any{"role": role}) if err != nil { return nil, err } // Illustrative: the containment predicate is EQL's. - rows, err := s.query(ctx, selectUser+` WHERE attrs @> $1`, contains) + encrypted, err := s.query(ctx, selectUser+` WHERE attrs @> $1`, contains) if err != nil { return nil, err } - return userRowPlan.DecryptAll(ctx, cipher, rows) + return stackencrypt.DecryptAll(ctx, cipher, encrypted) } -func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]UserRow, error) { +func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]EncryptedUser, error) { rs, err := s.db.QueryContext(ctx, q, params...) if err != nil { return nil, err } defer rs.Close() - var found []UserRow + var found []EncryptedUser for rs.Next() { - var r UserRow - if err := rs.Scan(&r.ID, &r.Email, &r.EmailEq, &r.EmailMatch, &r.Age, &r.AgeEq, &r.AgeOre, &r.Attrs, &r.Notes); err != nil { + var e EncryptedUser + if err := rs.Scan(&e.ID, &e.Email.Ciphertext, &e.Email.Equality, &e.Email.Match, + &e.Age.Ciphertext, &e.Age.Equality, &e.Age.Ore, &e.Attrs, &e.Notes); err != nil { return nil, err } - found = append(found, r) + found = append(found, e) } return found, rs.Err() } -func args(r UserRow) []any { - return []any{r.ID, r.Email, r.EmailEq, r.EmailMatch, r.Age, r.AgeEq, r.AgeOre, r.Attrs, r.Notes} +func args(e EncryptedUser) []any { + return []any{e.ID, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, + e.Age.Ciphertext, e.Age.Equality, e.Age.Ore, e.Attrs, e.Notes} } diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go new file mode 100644 index 000000000..e124f9f7c --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -0,0 +1,122 @@ +// Code generated by stashgen. DO NOT EDIT. + +package users + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +// EncryptedUser is a User as stored. It has one field for each field of User +// that the plan stores, and each field holds only the outputs the plan +// declares for it. +type EncryptedUser struct { + ID int64 `stash:"id"` + Email EncryptedUserEmail `stash:"email"` + Age EncryptedUserAge `stash:"age"` + Attrs stackencrypt.JSONDocument `stash:"attrs,json"` + Notes stackencrypt.Ciphertext `stash:"notes"` +} + +type EncryptedUserEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Match stackencrypt.MatchTerm +} + +type EncryptedUserAge struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Ore stackencrypt.OreTerm +} + +var userPlan = stackencrypt.MustRowPlan[EncryptedUser](stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]())) + +func (User) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } + +func (EncryptedUser) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } + +// UserFields has one entry for each sealed field of User. An entry has a query +// method only for an index the field declares. +var UserFields = struct { + Email UserEmailField + Age UserAgeField + Attrs UserAttrsField + Notes UserNotesField +}{ + Email: UserEmailField{stackencrypt.MustField[string](userPlan.Record(), "email")}, + Age: UserAgeField{stackencrypt.MustField[uint32](userPlan.Record(), "age")}, + Attrs: UserAttrsField{stackencrypt.MustField[map[string]any](userPlan.Record(), "attrs")}, + Notes: UserNotesField{stackencrypt.MustField[string](userPlan.Record(), "notes")}, +} + +type UserEmailField struct { + plan *stackencrypt.ValuePlan[string] +} + +func (f UserEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedUserEmail, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedUserEmail{}, err + } + return EncryptedUserEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil +} + +func (f UserEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} + +func (f UserEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { + return f.plan.Match(ctx, c, v, opts...) +} + +type UserAgeField struct { + plan *stackencrypt.ValuePlan[uint32] +} + +func (f UserAgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (EncryptedUserAge, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedUserAge{}, err + } + return EncryptedUserAge{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil +} + +func (f UserAgeField) Equality(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} + +func (f UserAgeField) Ore(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (stackencrypt.OreTerm, error) { + return f.plan.Ore(ctx, c, v, opts...) +} + +type UserAttrsField struct { + plan *stackencrypt.ValuePlan[map[string]any] +} + +func (f UserAttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONDocument, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + return out.JSON, err +} + +func (f UserAttrsField) Contains(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONQuery, error) { + return f.plan.Contains(ctx, c, v, opts...) +} + +func (f UserAttrsField) Selector(ctx context.Context, c *stackencrypt.Cipher, path stackencrypt.JSONPath, opts ...stackencrypt.Option) (stackencrypt.JSONSelector, error) { + return f.plan.Selector(ctx, c, path, opts...) +} + +func (f UserAttrsField) EqualAt(ctx context.Context, c *stackencrypt.Cipher, path stackencrypt.JSONPath, v any, opts ...stackencrypt.Option) (stackencrypt.JSONQuery, error) { + return f.plan.EqualAt(ctx, c, path, v, opts...) +} + +type UserNotesField struct { + plan *stackencrypt.ValuePlan[string] +} + +func (f UserNotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + return out.Ciphertext, err +} From 3c44aaba1e1ba4d7f275f0b1f4c8f1c73acca043 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 12:54:05 +1100 Subject: [PATCH 04/22] docs(plans): design stashgen, and check storage structs at compile time Fleshes out the generator in the plan, and removes the two places where the Go design still checked at run time what the compiler can check. The plan states the design; the reasons are here. Storage structs. The last commit kept RowPlan with a check at package init for a struct another tool owns (a GORM model, an sqlc model). That went against the rule this design follows: find the mistake at compile time. stashgen now reads the storage struct's Go type and writes the conversion to and from it. The generated file holds a copy of the struct's fields and converts between the copy and the struct. Go allows that conversion only while both have the same fields, with the same types, in the same order, so a change to the struct stops the build. NewRowPlan and its init check are gone. The same check guards the tagged struct itself and a type in another package. This was tested, not assumed. With the examples in a scratch module, each of these failed `go build` with "cannot convert": a field added to the GORM model, a column type changed in the sqlc model, and a field added to crm.Contact. The limit is stated in the plan. Go does not convert a struct from another package that has an unexported field (checked with sync.Once), so a protobuf message or an ent entity gets field-by-field assignment. The compiler then finds a removed or retyped field, and CI finds an added one. Any generator. stashgen reads Go types, not a tool's format, so the storage struct can come from GORM, sqlc, ent, sqlboiler or anything else. A tool with no way to put a tag on its output is handled by -row Name=R:D, where a struct in the caller's package declares the tags. Types in another package. -for lets a local struct declare the plan for a type that cannot carry tags. This replaces the hand-built plan example, and it removes the need for a protobuf plugin, so that open question is deleted. A second front end has to earn its place, and nothing here needs one. No run-time tag reading. PlanOf is removed. Tags are read only by stashgen, so there is no way to use tags with run-time checks only. The checks Bind made for a tagged struct (every field named, each index fits its type) move to `go generate`. The generated file builds the plan with the builder and converts values with plain assignments, so it uses no reflection. Standalone value plans are removed as well: a value with no record around it is a struct with one field, and gets the same generated type. What stays at run time is the policy package, which decides a plan when the program starts. The plan says so in one place. The design section covers the flags, what the file holds, what stashgen refuses, how each kind of mistake is found, and the support functions that only generated code calls. stashgen does not exist. The three _stash.go files are written by hand as the files it will write. Every Go file type-checks (`go vet ./...`) against the uncommitted stub of the proposed API, and gofmt reports nothing. The amended text passes slipstream's language, length and line-break checks with 0 findings (147 sentences, the longest 24 words), and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 265 +++++++++++------- docs/plans/2026-10-04-plan-builder/README.md | 16 +- .../blocklist/blocked_stash.go | 79 ++++++ .../blocklist/blocklist.go | 35 ++- .../contacts/contacts.go | 53 ++-- .../contacts/contactstash_stash.go | 118 ++++++++ .../2026-10-04-plan-builder/crm/contact.go | 9 + .../individuals/individuals.go | 2 +- .../users/gormstore.go | 10 +- .../2026-10-04-plan-builder/users/model.go | 2 +- .../users/sqlcstore.go | 16 +- .../users/user_stash.go | 133 ++++++++- 12 files changed, 565 insertions(+), 173 deletions(-) create mode 100644 docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/crm/contact.go diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 0642b35d9..efe9292fa 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -504,9 +504,9 @@ the type. ## The Go binding Go uses the same plans as Rust, in Go's own call shape. -A plan is built and checked once, then held in a package-level variable. +A struct's `stash` tags declare its plan, and a generator, `stashgen`, writes the encrypted type and the plan from them. Every operation takes `ctx` first and the cipher second, and it returns a concrete type. -For a struct with `stash` tags, a generator writes the encrypted type, so the compiler checks each field and each index. +The compiler checks each field, each index and each storage struct. [The Go examples](2026-10-04-plan-builder/README.md) show each part below as a complete program. ```go @@ -530,25 +530,9 @@ _ = enc.Email.Equality // EqualityTerm _ = enc.Email.Ore // does not compile: email declares no ORE index ``` -### Plan types - -Four types hold a plan: +### Struct tags -- `Plan` is the untyped data form. - `NewPlan`, `PlanOf` and the policy package make one, and the guest receives one. -- `RecordPlan[T]` is a `Plan` that `Bind[T]` binds to the struct type `T`. -- `ValuePlan[T]` is a plan for one value of type `T`. - `NewValuePlan[T]` makes one, and `Field[V]` takes one field of a record plan as one. -- `RowPlan[T, R]` is a record plan whose output is the storage struct `R`. - `NewRowPlan[R]` makes one. - -Each constructor returns an error, and each has a `Must` form that panics, for package-level variables. -A plan does not change after it is built, and any number of goroutines can use it at the same time. -See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go) and [`blocklist/blocklist.go`](2026-10-04-plan-builder/blocklist/blocklist.go). - -### Plans from struct tags - -`PlanOf[T]` reads the `stash` tag on each field of `T`: +The `stash` tag on each field declares what the plan does with it: | Tag | Field verb | |---|---| @@ -559,67 +543,175 @@ See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go) and [`blo | `stash:"id,passthrough"` | `Passthrough` | | `stash:"-"` | `Omit` | +The first part of a tag is the field's name in the plan, which is the column name in a database. The index names are `equality`, `match`, `ore`, `ope` and `json`. -`PlanOf` refuses an exported field with no `stash` tag. -It ignores unexported fields. +An index takes its options in the tag, in parentheses after its name. The fields of an embedded struct are fields of the outer struct. +Only `stashgen` reads these tags, and no function reads them at run time. + +### stashgen -### Generated types +`stashgen` is a Go command at `languages/golang/cmd/stashgen`. +A module adds it with `go get -tool`, and `go generate` runs it from a comment beside the type: + +```go +//go:generate go tool stashgen -type User -row UserRows=UserRow -row SQLCUsers=userdb.User +``` + +It takes these flags: + +| Flag | Meaning | +|---|---| +| `-type T` | The struct that carries the `stash` tags. Required. | +| `-row Name=R` | A storage struct `R`, and the name of the variable that encrypts into it. Any number. | +| `-row Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` declares the tags. | +| `-for P.F` | `T` declares the plan for `F`, a type in another package. | +| `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | -`stashgen` is a Go command in the module, and `go generate` runs it. -It reads the `stash` tags of one struct and writes one file beside it. -For `User`, the file `user_stash.go` holds: +`stashgen` loads the package with `golang.org/x/tools/go/packages` and reads types, not text. +It runs none of the package's code. +The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. -- `EncryptedUser`, with one field for each field of `User` that the plan stores. +#### What stashgen writes + +For `-type User`, the file `user_stash.go` holds: + +- **`EncryptedUser`.** + It has one field for each field of `User` that the plan stores. A field with one output has that output's type, such as `Ciphertext`. A field with more outputs has its own struct, such as `EncryptedUserEmail`, with one field for each output. -- The plan, built once at package init. -- A `StashPlan` method on `User` and on `EncryptedUser`. - The method gives each type the `Planned` interface, so `stackencrypt.Encrypt` and `stackencrypt.Decrypt` find the plan and the result type from the value. -- `UserFields`, with one entry for each sealed field. +- **The plan.** + The file builds it with the builder at package init, and converts values with plain assignments. + Generated code uses no reflection. +- **A `StashPlan` method on `User` and on `EncryptedUser`.** + The method gives each type the `Planned` interface. + `stackencrypt.Encrypt` and `stackencrypt.Decrypt` then find the plan and the result type from the value. +- **`UserFields`.** + It has one entry for each sealed field. An entry encrypts one value of that field, and it has a query method only for an index the field declares. -The compiler then checks every use. -The fields `enc.Email.Ore` and `UserFields.Email.Ore` do not compile, because `email` declares no ORE index. -A call to `stackencrypt.Encrypt` does not compile for a type that has no `StashPlan` method. - -The generated file is committed. -CI runs `go generate` and fails when the result differs from the committed file. -A stale file that still compiles panics at package init, because the plan checks `EncryptedUser` against the tags. +The file also converts `User` to a copy of its fields, so a change to the fields of `User` stops the build. +The generated code copies a passthrough field itself, so that field does not cross into the guest. See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_stash.go`](2026-10-04-plan-builder/users/user_stash.go). -A struct that another tool generates cannot use `stashgen`. -For an sqlc model or a GORM model, `NewRowPlan[R]` checks the struct at package init. +#### What stashgen refuses + +`stashgen` stops with an error, and writes no file, for each of these: + +- an exported field with no `stash` tag; +- a tag that does not parse, or two fields with one name; +- a struct with no `context=` field; +- an index that does not apply to the field's Go type, such as `match` on a `uint32`; +- a field type that the engine cannot seal; +- a `passthrough` field that has an index. + +#### Storage structs + +A storage struct has one field for each column. +It can come from any tool, such as GORM, sqlc, ent or sqlboiler, because `stashgen` reads Go types and not a tool's own format. +Each of its fields carries a `stash` tag that names one output: +`stash:"email"` is the ciphertext of `email`, and `stash:"email,equality"` is its equality term. +For sqlc, a `go_struct_tag` override puts the tag on the generated model. + +For `-row UserRows=UserRow`, `stashgen` writes `UserRows`, a `RowPlan[User, UserRow]`. +Its `Encrypt` returns a `UserRow`, and its `Decrypt` takes one. +The generator refuses a storage struct that has a field with no tag. +It also refuses one that has no field for an output of the plan. + +Some tools give no way to put a tag on the struct they write. +For those, `-row Name=R:D` names a struct `D` in your own package. +`D` has the same field names as `R`, and its fields carry the tags. + +The generated file also holds a copy of the storage struct's fields, and it converts between the copy and the struct. +Go allows that conversion only while the two have the same fields, with the same types, in the same order. +So a change to the storage struct stops the build until `go generate` runs again. + +That conversion needs a struct whose fields are all exported. +A struct from another package with an unexported field, such as a protobuf message or an ent entity, cannot convert. +For such a struct the generated file assigns each field by name. +The compiler then finds a removed field and a field with a new type, and CI finds an added field. +See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go) and [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go). + +#### Types in another package + +A type in another package cannot carry `stash` tags, and it cannot get a `StashPlan` method. +For such a type, a struct in your own package declares the plan: + +```go +//go:generate go tool stashgen -type contactStash -for crm.Contact +type contactStash struct { + _ struct{} `stash:"context=contacts"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + PhoneNumber string `stash:"phone_number,encrypt,index=equality"` + Internal string `stash:"-"` +} +``` + +`stashgen` matches each field to the field of `crm.Contact` with the same name and type. +It refuses a field of `crm.Contact` that the struct does not name. +It writes `EncryptedContact`, `ContactFields` and an exported `ContactPlan`, a `RowPlan[crm.Contact, EncryptedContact]`. +The file converts `crm.Contact` to a copy of its fields, so a change to `crm.Contact` stops the build. +See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). + +#### When the compiler finds a mistake + +| Mistake | Found by | +|---|---| +| A read of an output that the plan does not declare, such as `enc.Email.Ore` | the compiler | +| A query for an index that the field does not declare | the compiler | +| `stackencrypt.Encrypt` with a type that has no plan | the compiler | +| A change to a storage struct, or to a type in another package | the compiler | +| A field added to a struct from another package that has an unexported field | CI | +| A field added to, removed from or retyped in the tagged struct | the compiler | +| A change to a tag only, with no `go generate` run | CI | + +CI runs `go generate ./...` and fails when a generated file differs from the committed file. +The compiler does not make those two checks. + +### Plan types + +- `Plan` is the untyped data form that the guest receives. + The builder makes one. +- `RowPlan[T, R]` encrypts a `T` into an `R` and decrypts an `R` into a `T`. + Only generated code makes one. +- `ValuePlan[V]` is the plan for one field, with the field's Go type `V`. + `Field[V]` takes one from a `Plan`. +- `RecordPlan[T]` is a `Plan` that `Bind[T]` binds to a struct type at run time. + Only the policy package needs it. + +A plan does not change after it is built, and any number of goroutines can use it at the same time. ### The builder -`NewPlan` makes the same kind of plan without tags: +`NewPlan` makes a `Plan`, and generated code and the policy package call it: ```go -contactsPlan, err := stackencrypt.NewPlan("contacts"). - Passthrough("id"). +plan, err := stackencrypt.NewPlan("contacts"). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). EncryptIndex("phone_number", stackencrypt.Equality). - Omit("internal"). Build() ``` -A field name is the record key, which is the column name in a database. `EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. Each method returns a new builder and does not change the builder it is called on. -`Build` checks the whole-plan rules and returns a `Plan`. -See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). +`Build` checks the whole-plan rules and returns an error, and `MustBuild` panics. + +### Support for generated code -`Bind[T]` matches each plan field to a field of `T`. -It uses the `stash` tag name first, then the Go field name that a policy fact records, then the Go field name in snake_case. -It refuses a plan field with no match, and a field of `T` that the plan does not name. -It also refuses an index that does not apply to the field's Go type, such as `Match` on an integer. +Generated code calls four things that other code does not need: + +- `Generated[T, E]` holds the plan and four conversion functions, and `MustGenerate` makes a `RowPlan[T, E]` from it. +- `Rows` makes a `RowPlan[T, R]` for a storage struct from a generated plan and two conversions. +- `Values` holds plaintext values by field name, and `Get[V]` reads one as the Go type `V`. +- `RecordOf` and `EncryptedRecord.MustField` build and read an `EncryptedRecord`. ### The policy package -`plan.PlanFor` makes a `Plan` from facts and a policy. -`plan.EQL` takes `stackencrypt.Index` values. -A field that the policy stores as plaintext is an `Omit` field in the plan, so `Bind` accepts the struct. +`plan.PlanFor` makes a `Plan` from facts and a policy at run time, and `plan.EQL` takes `stackencrypt.Index` values. +`Bind[T]` binds that plan to a struct type, and `RecordPlan[T].Encrypt` returns an `EncryptedRecord`. +This is the one path that the program checks at run time, because the policy decides the plan when the program starts. +`Bind` and `EncryptedRecord.Field` return an error for a field that does not match. See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individuals.go). ### Indexes @@ -630,29 +722,23 @@ See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individua ### Operations -| Method | Returns | +| Call | Returns | |---|---| -| `Encrypt(ctx, c *Cipher, v T, opts ...Option)`, for a `Planned` type | the generated type `R` | -| `EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]R` | -| `Decrypt(ctx, d Decrypter, r R, opts ...Option)` | `T` | -| `DecryptAll(ctx, d Decrypter, rs []R, opts ...Option)` | `[]T` | -| `RecordPlan[T].Encrypt(ctx, c *Cipher, v T, opts ...Option)` | `EncryptedRecord` | -| `RecordPlan[T].EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]EncryptedRecord` | -| `RecordPlan[T].Decrypt(ctx, d Decrypter, r EncryptedRecord, opts ...Option)` | `T` | -| `RecordPlan[T].DecryptAll(ctx, d Decrypter, rs []EncryptedRecord, opts ...Option)` | `[]T` | -| `RowPlan[T, R]`: the same four methods | `R` in place of `EncryptedRecord` | -| `ValuePlan[T]`: the same four methods | `EncryptedField` in place of `EncryptedRecord` | -| `ValuePlan[T].Equality(ctx, c *Cipher, v T, opts ...Option)` | `EqualityTerm` | -| `ValuePlan[T].Match`, `ValuePlan[T].Ore`, `ValuePlan[T].Ope` | `MatchTerm`, `OreTerm`, `OpeTerm` | -| `ValuePlan[T].Contains(ctx, c *Cipher, v T, opts ...Option)` | `JSONQuery` | -| `ValuePlan[T].Selector(ctx, c *Cipher, path JSONPath, opts ...Option)` | `JSONSelector` | -| `ValuePlan[T].EqualAt(ctx, c *Cipher, path JSONPath, v any, opts ...Option)` | `JSONQuery` | +| `Encrypt(ctx, c *Cipher, v T, opts ...Option)`, for a `Planned` type | the generated type `E` | +| `EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]E` | +| `Decrypt(ctx, d Decrypter, e E, opts ...Option)` | `T` | +| `DecryptAll(ctx, d Decrypter, es []E, opts ...Option)` | `[]T` | +| `RowPlan[T, R]`: the same four, as methods | `R` in place of `E` | +| `RecordPlan[T]`: the same four, as methods | `EncryptedRecord` in place of `E` | +| A generated field entry, such as `UserFields.Email`: `Encrypt` | the field's generated type | +| A generated field entry: `Equality`, `Match`, `Ore`, `Ope` | `EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` | +| A generated field entry: `Contains`, `EqualAt` | `JSONQuery` | +| A generated field entry: `Selector` | `JSONSelector` | | `Cipher.Encrypt(ctx, v any, c Context, opts ...Option)` | `Ciphertext`, one tree | | `DecryptValue[T](ctx, d Decrypter, ct Ciphertext, c Context, opts ...Option)` | `T` | -Every method also returns an `error`. +Every call also returns an `error`. `EncryptAll` and `DecryptAll` send one ZeroKMS request for the whole slice. -A query method for an index that the field does not declare returns `ErrIndexNotDeclared`. `NewContext(bytes)` makes a `Context` from raw bytes, the same bytes part that Rust accepts. See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go) and [`documents/documents.go`](2026-10-04-plan-builder/documents/documents.go). @@ -670,23 +756,17 @@ See [`users/extend.go`](2026-10-04-plan-builder/users/extend.go). ### Output types -`EncryptedRecord.Field(name)` returns the named field's `EncryptedField`. -For a name that the plan does not have, it returns `ErrUnknownField` and never a zero value. -`EncryptedField` holds `Ciphertext`, `Equality`, `Match`, `Ore`, `Ope` and `JSON`. -An output that the plan does not declare is nil. - `Ciphertext`, each term type, `JSONDocument`, `JSONQuery` and `JSONSelector` implement `driver.Valuer`. `Ciphertext`, each term type and `JSONDocument` also implement `sql.Scanner`. +So a field of a generated type, or of a storage struct, goes straight to `ExecContext` and to `Scan`. -`NewRowPlan[R]` reads the `stash` tags of `R`. -`stash:"email"` holds the field's ciphertext, and `stash:"email,equality"` holds one of its terms. -A struct field with `stash:"email"` holds all the outputs of `email`, in fields named `Ciphertext`, `Equality`, `Match`, `Ore`, `Ope` and `JSON`. -`NewRowPlan` refuses a field of `R` with no tag, and a plan output with no field in `R`. -`RowPlan[T, R].Record()` returns the record plan, so one plan can have more than one `R`. +`EncryptedRecord` is the output of the policy path. +`EncryptedRecord.Field(name)` returns the named field's `EncryptedField`. +For a name that the plan does not have, it returns `ErrUnknownField` and never a zero value. ### Errors -`Build`, `Bind`, `NewRowPlan`, `Field` and every operation return a `*PlanError`. +`Build`, `Bind`, `Field` and every operation return a `*PlanError`. A `*PlanError` names the field and wraps one rule: - `ErrUnknownField`: the plan has no field with that name. @@ -702,10 +782,10 @@ No error holds a plaintext value. - **database/sql:** pass the fields of the generated type to `ExecContext`, and `Scan` into them. See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go). -- **GORM:** the store encrypts and decrypts outside GORM, and the `R` of a `RowPlan` is the GORM model. +- **GORM:** the store encrypts and decrypts outside GORM, and the GORM model is a storage struct. See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go). - **sqlc:** column overrides set each column's `go_type`, and `go_struct_tag` puts the `stash` tag on the generated struct. - The generated model is `R`, and an `INSERT` params struct converts from it. + The sqlc model is a storage struct, and an `INSERT` params struct converts from it. See [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go) and [`sqlc/sqlc.yaml`](2026-10-04-plan-builder/sqlc/sqlc.yaml). - **sqlc with EQL domain columns:** sqlc reads a file that declares the domains in place of the EQL install bundle, which it cannot parse. See [the three rules for EQL domain columns](2026-10-04-plan-builder/README.md#use-sqlc-with-eql-domain-columns). @@ -717,12 +797,12 @@ The binding has never been released, so these are removed, not deprecated: - `Cipher.Encrypt(ctx, v, aad []byte)`: `Cipher.Encrypt` takes a `Context`. - `Cipher.Decrypt` and `Client.Decrypt`: `DecryptValue[T]` replaces them. - `EncryptElement` and `DecryptElement`. -- `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`: `RecordPlan[T]` replaces them. -- `Cipher.Term`: the query methods of `ValuePlan[T]` replace it. +- `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. +- `Cipher.Term`: the query methods of a generated field entry replace it. - `RecordOption` and `WithPlan`. - `TermKind`: `Index` replaces it. - `FieldPlan`, `NewPlan(fields ...FieldPlan)` and `Plan.Validate`: the builder and `Bind[T]` replace them. -- `PlanFromTags`: `PlanOf[T]` replaces it. +- `PlanFromTags`, and every other function that reads `stash` tags at run time: `stashgen` replaces them. - The rule that a zero `Plan` means the struct's tags. - `Sealed`, `SealedNone`, `SealedEmptyMap` and `SealedEmptySeq` as storage types: `Ciphertext` replaces them. @@ -763,9 +843,9 @@ source. |---|---|---|---|---|---| | Shell | in-process | WASI guest (wazero) | napi; the guest for `wasm-inline` on the edge | PyO3 | P/Invoke to a cdylib | | Finalizer | `.await` | none: each method runs when called, `ctx` first | `await` (thenable chain) | `await` plus sync `.run()` | `await` (`GetAwaiter`) or `RunAsync(ct)` | -| Plan from a type | derive | tags and `stashgen`; `PlanOf[T]()` then `Bind[T]` with no generator | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | -| Index applies to type | compile time | `Bind[T]` | partly via conditional types | build | partly via constraints | -| Typed output | `Encrypted`, derived struct | a generated struct (`stashgen`); `RowPlan[T, R]` for a struct another tool owns | inferred from the plan | dict or the dataclass | `Plan`, `Task` | +| Plan from a type | derive | tags and `stashgen` | schema builder or decorators | `Plan.of(User)` over `Annotated` | attributes; reflection or a source generator | +| Index applies to type | compile time | `go generate` | partly via conditional types | build | partly via constraints | +| Typed output | `Encrypted`, derived struct | a generated struct (`stashgen`) | inferred from the plan | dict or the dataclass | `Plan`, `Task` | | Query form | source type | one method for each form | overloads or union | runtime type | overloads | The fail-closed `build()` checks are the floor everywhere; compile-time checks @@ -921,9 +1001,6 @@ Then: - **A batch across plans in Go.** `EncryptAll` and `DecryptAll` batch the values of one plan. The Go form of `all(..)` gives a typed handle for each operation, and the Go PR settles its spelling. -- **A protobuf front end for `stashgen`.** - A protobuf message cannot carry `stash` tags. - A protoc plugin reads field options and writes the same generated file, and the protobuf source PR settles it. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index ec4605a07..0e71894ed 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -9,22 +9,22 @@ The module path is `example.com/app`. | File | What it shows | |---|---| | [`main.go`](main.go) | A client, one keyset for each tenant, and calls into every package below | -| [`users/model.go`](users/model.go) | A plan in struct tags, and the `go:generate` line that runs `stashgen` | -| [`users/user_stash.go`](users/user_stash.go) | The file that `stashgen` writes: the encrypted type, the plan, and the typed fields | +| [`users/model.go`](users/model.go) | A plan in struct tags, and the `go:generate` line that runs `stashgen` with two storage structs | +| [`users/user_stash.go`](users/user_stash.go) | The file that `stashgen` writes: the encrypted type, the plan, the typed fields, and the storage struct conversions | | [`users/sqlstore.go`](users/sqlstore.go) | `database/sql` with the generated type: insert, batch insert in a transaction, equality search, ORE ordering and JSON containment | -| [`users/gormstore.go`](users/gormstore.go) | GORM, with a hand-written model that a row plan checks at package init | -| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with the sqlc model in a row plan, and an update of one field and its terms | +| [`users/gormstore.go`](users/gormstore.go) | GORM, with a hand-written model as a storage struct | +| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with the sqlc model as a storage struct, and an update of one field and its terms | | [`users/extend.go`](users/extend.go) | A context extension on the write, the query and the read | | [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | -| [`contacts/contacts.go`](contacts/contacts.go) | A plan built by hand for a type with no tags | -| [`individuals/individuals.go`](individuals/individuals.go) | A plan from the policy package, for a type that cannot carry tags | -| [`blocklist/blocklist.go`](blocklist/blocklist.go) | A value plan for a value with no record around it | +| [`contacts/contacts.go`](contacts/contacts.go) | A plan for [`crm.Contact`](crm/contact.go), a type in another package, with its generated file [`contactstash_stash.go`](contacts/contactstash_stash.go) | +| [`individuals/individuals.go`](individuals/individuals.go) | A plan from the policy package, which the program checks at run time | +| [`blocklist/blocklist.go`](blocklist/blocklist.go) | One value with no record around it, as a struct with one field, with its generated file [`blocked_stash.go`](blocklist/blocked_stash.go) | | [`documents/documents.go`](documents/documents.go) | One value sealed as one tree, decrypted with the client | | [`eql-sqlc/`](eql-sqlc/) | sqlc with EQL v3 domain columns, which generates [`internal/eqldb/`](internal/eqldb/) | ## Generate the encrypted type -`stashgen` does not exist yet, so [`users/user_stash.go`](users/user_stash.go) is written by hand as the file it will write. +`stashgen` does not exist yet, so the three `_stash.go` files are written by hand as the files it will write. ## Generate the sqlc packages diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go new file mode 100644 index 000000000..2da842775 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go @@ -0,0 +1,79 @@ +// Code generated by stashgen. DO NOT EDIT. + +package blocklist + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +type EncryptedBlocked struct { + Email EncryptedBlockedEmail +} + +type EncryptedBlockedEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm +} + +// Stops compiling when Blocked gains, loses, reorders or retypes a field. +var _ = blockedShape(Blocked{}) + +type blockedShape struct { + _ struct{} + Email string +} + +var blockedPlan = stackencrypt.MustGenerate(stackencrypt.Generated[Blocked, EncryptedBlocked]{ + Plan: stackencrypt.NewPlan("blocked_emails"). + EncryptIndex("email", stackencrypt.Equality). + MustBuild(), + Source: func(v Blocked) stackencrypt.Values { + return stackencrypt.Values{"email": v.Email} + }, + Seal: func(v Blocked, rec stackencrypt.EncryptedRecord) EncryptedBlocked { + email := rec.MustField("email") + return EncryptedBlocked{Email: EncryptedBlockedEmail{Ciphertext: email.Ciphertext, Equality: email.Equality}} + }, + Open: func(e EncryptedBlocked) stackencrypt.EncryptedRecord { + return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality}, + }) + }, + Value: func(e EncryptedBlocked, vals stackencrypt.Values) (Blocked, error) { + email, err := stackencrypt.Get[string](vals, "email") + if err != nil { + return Blocked{}, err + } + return Blocked{Email: email}, nil + }, +}) + +func (Blocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBlocked] { return blockedPlan } + +func (EncryptedBlocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBlocked] { + return blockedPlan +} + +var BlockedFields = struct { + Email BlockedEmailField +}{ + Email: BlockedEmailField{stackencrypt.MustField[string](blockedPlan.Plan(), "email")}, +} + +type BlockedEmailField struct { + plan *stackencrypt.ValuePlan[string] +} + +func (f BlockedEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedBlockedEmail, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedBlockedEmail{}, err + } + return EncryptedBlockedEmail{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f BlockedEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go index 3c65b58e8..f285ec59d 100644 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go @@ -1,5 +1,5 @@ -// Package blocklist keeps blocked email addresses. Each address is one value -// with no record around it, so it uses a value plan. +// Package blocklist keeps blocked email addresses. A value with no record +// around it is a struct with one field. package blocklist import ( @@ -9,7 +9,12 @@ import ( "github.com/cipherstash/stack/languages/golang/stackencrypt" ) -var blockedPlan = stackencrypt.MustValuePlan[string]("blocked_emails/email", stackencrypt.Equality) +//go:generate go tool stashgen -type Blocked + +type Blocked struct { + _ struct{} `stash:"context=blocked_emails"` + Email string `stash:"email,encrypt,index=equality"` +} type List struct { db *sql.DB @@ -21,18 +26,18 @@ func New(db *sql.DB, cipher *stackencrypt.Cipher) *List { } func (l *List) Block(ctx context.Context, email string) error { - field, err := blockedPlan.Encrypt(ctx, l.cipher, email) + enc, err := stackencrypt.Encrypt(ctx, l.cipher, Blocked{Email: email}) if err != nil { return err } _, err = l.db.ExecContext(ctx, `INSERT INTO blocked_emails (email, email_eq) VALUES ($1, $2) ON CONFLICT (email_eq) DO NOTHING`, - field.Ciphertext, field.Equality) + enc.Email.Ciphertext, enc.Email.Equality) return err } func (l *List) Blocked(ctx context.Context, email string) (bool, error) { - term, err := blockedPlan.Equality(ctx, l.cipher, email) + term, err := BlockedFields.Email.Equality(ctx, l.cipher, email) if err != nil { return false, err } @@ -51,16 +56,24 @@ func (l *List) All(ctx context.Context) ([]string, error) { } defer rs.Close() - var fields []stackencrypt.EncryptedField + var encrypted []EncryptedBlocked for rs.Next() { - var sealed stackencrypt.Ciphertext - if err := rs.Scan(&sealed); err != nil { + var e EncryptedBlocked + if err := rs.Scan(&e.Email.Ciphertext); err != nil { return nil, err } - fields = append(fields, stackencrypt.EncryptedField{Ciphertext: sealed}) + encrypted = append(encrypted, e) } if err := rs.Err(); err != nil { return nil, err } - return blockedPlan.DecryptAll(ctx, l.cipher, fields) + blocked, err := stackencrypt.DecryptAll(ctx, l.cipher, encrypted) + if err != nil { + return nil, err + } + emails := make([]string, len(blocked)) + for i, b := range blocked { + emails[i] = b.Email + } + return emails, nil } diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go index bde7627fe..1c36f2174 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -1,45 +1,46 @@ -// Package contacts builds a plan by hand for a type with no stash tags. +// Package contacts encrypts crm.Contact, a type that cannot carry stash tags. package contacts import ( "context" "database/sql" + "example.com/app/crm" "github.com/cipherstash/stack/languages/golang/stackencrypt" ) -// Contact has no stash tags. Bind matches each plan field to the struct field -// whose name, in snake_case, is the plan field's name. -type Contact struct { - ID int64 - Email string - PhoneNumber string - Internal string -} +//go:generate go tool stashgen -type contactStash -for crm.Contact -var contactsPlan = stackencrypt.MustBind[Contact](stackencrypt.NewPlan("contacts"). - Passthrough("id"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("phone_number", stackencrypt.Equality). - Omit("internal"). - MustBuild()) +// contactStash declares the plan for crm.Contact. stashgen matches each field +// to the crm.Contact field with the same name and type, and refuses a +// crm.Contact field this struct does not name. +type contactStash struct { + _ struct{} `stash:"context=contacts"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + PhoneNumber string `stash:"phone_number,encrypt,index=equality"` + Internal string `stash:"-"` +} -func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, contact Contact) error { - record, err := contactsPlan.Encrypt(ctx, cipher, contact) - if err != nil { - return err - } - email, err := record.Field("email") - if err != nil { - return err - } - phone, err := record.Field("phone_number") +func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, contact crm.Contact) error { + enc, err := ContactPlan.Encrypt(ctx, cipher, contact) if err != nil { return err } _, err = db.ExecContext(ctx, ` INSERT INTO contacts (id, email, email_eq, email_match, phone_number, phone_number_eq) VALUES ($1, $2, $3, $4, $5, $6)`, - contact.ID, email.Ciphertext, email.Equality, email.Match, phone.Ciphertext, phone.Equality) + enc.ID, enc.Email.Ciphertext, enc.Email.Equality, enc.Email.Match, + enc.PhoneNumber.Ciphertext, enc.PhoneNumber.Equality) return err } + +func IDByPhone(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, phone string) (int64, error) { + term, err := ContactFields.PhoneNumber.Equality(ctx, cipher, phone) + if err != nil { + return 0, err + } + var id int64 + err = db.QueryRowContext(ctx, `SELECT id FROM contacts WHERE phone_number_eq = $1`, term).Scan(&id) + return id, err +} diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go new file mode 100644 index 000000000..a82a8d02e --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -0,0 +1,118 @@ +// Code generated by stashgen. DO NOT EDIT. + +package contacts + +import ( + "context" + + "example.com/app/crm" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +type EncryptedContact struct { + ID int64 + Email EncryptedContactEmail + PhoneNumber EncryptedContactPhoneNumber +} + +type EncryptedContactEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Match stackencrypt.MatchTerm +} + +type EncryptedContactPhoneNumber struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm +} + +// Stops compiling when crm.Contact gains, loses, reorders or retypes a field. +var _ = contactShape(crm.Contact{}) + +type contactShape struct { + ID int64 + Email string + PhoneNumber string + Internal string +} + +// crm.Contact is not this package's type, so it cannot have a StashPlan +// method. Call the plan's methods. +var ContactPlan = stackencrypt.MustGenerate(stackencrypt.Generated[crm.Contact, EncryptedContact]{ + Plan: stackencrypt.NewPlan("contacts"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("phone_number", stackencrypt.Equality). + MustBuild(), + Source: func(v crm.Contact) stackencrypt.Values { + return stackencrypt.Values{"email": v.Email, "phone_number": v.PhoneNumber} + }, + Seal: func(v crm.Contact, rec stackencrypt.EncryptedRecord) EncryptedContact { + email, phone := rec.MustField("email"), rec.MustField("phone_number") + return EncryptedContact{ + ID: v.ID, + Email: EncryptedContactEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, + PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, + } + }, + Open: func(e EncryptedContact) stackencrypt.EncryptedRecord { + return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, + }) + }, + Value: func(e EncryptedContact, vals stackencrypt.Values) (crm.Contact, error) { + v := crm.Contact{ID: e.ID} + var err error + if v.Email, err = stackencrypt.Get[string](vals, "email"); err != nil { + return crm.Contact{}, err + } + if v.PhoneNumber, err = stackencrypt.Get[string](vals, "phone_number"); err != nil { + return crm.Contact{}, err + } + return v, nil + }, +}) + +var ContactFields = struct { + Email ContactEmailField + PhoneNumber ContactPhoneNumberField +}{ + Email: ContactEmailField{stackencrypt.MustField[string](ContactPlan.Plan(), "email")}, + PhoneNumber: ContactPhoneNumberField{stackencrypt.MustField[string](ContactPlan.Plan(), "phone_number")}, +} + +type ContactEmailField struct { + plan *stackencrypt.ValuePlan[string] +} + +func (f ContactEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactEmail, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedContactEmail{}, err + } + return EncryptedContactEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil +} + +func (f ContactEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} + +func (f ContactEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { + return f.plan.Match(ctx, c, v, opts...) +} + +type ContactPhoneNumberField struct { + plan *stackencrypt.ValuePlan[string] +} + +func (f ContactPhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactPhoneNumber, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedContactPhoneNumber{}, err + } + return EncryptedContactPhoneNumber{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f ContactPhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} diff --git a/docs/plans/2026-10-04-plan-builder/crm/contact.go b/docs/plans/2026-10-04-plan-builder/crm/contact.go new file mode 100644 index 000000000..38dacaf34 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/crm/contact.go @@ -0,0 +1,9 @@ +// Package crm stands in for a package this program does not own. +package crm + +type Contact struct { + ID int64 + Email string + PhoneNumber string + Internal string +} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individuals.go b/docs/plans/2026-10-04-plan-builder/individuals/individuals.go index 725ad053e..8010b94ed 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individuals.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individuals.go @@ -55,7 +55,7 @@ var individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), // omitted fields, so Bind accepts Individual. var ( individualsPlan = stackencrypt.MustBind[Individual](plan.MustPlanFor(source, individuals)) - medicarePlan = stackencrypt.MustField[string](individualsPlan, "medicare_number") + medicarePlan = stackencrypt.MustField[string](individualsPlan.Plan(), "medicare_number") ) func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person Individual) error { diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index 2047395f8..2e4a3ca0b 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -8,8 +8,8 @@ import ( ) // UserRow is the GORM model: one field for each column. GORM's default naming -// maps EmailEq to email_eq. It is written by hand, so the row plan checks it -// against the plan at package init. +// maps EmailEq to email_eq. stashgen reads its stash tags and writes UserRows, +// which stops compiling when this struct changes. type UserRow struct { ID int64 `stash:"id"` Email stackencrypt.Ciphertext `stash:"email"` @@ -24,8 +24,6 @@ type UserRow struct { func (UserRow) TableName() string { return "users" } -var gormRowPlan = stackencrypt.MustRowPlan[UserRow](userPlan.Record()) - // GormStore encrypts before GORM sees a value. driver.Valuer gets no // context.Context and runs one field at a time, so it cannot batch a ZeroKMS // request or stop when the request is cancelled. An AfterFind hook works too, @@ -41,7 +39,7 @@ func NewGormStore(db *gorm.DB, client *stackencrypt.Client) *GormStore { func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) error { cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - rows, err := gormRowPlan.EncryptAll(ctx, cipher, people) + rows, err := UserRows.EncryptAll(ctx, cipher, people) if err != nil { return err } @@ -58,5 +56,5 @@ func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]Us if err := s.db.WithContext(ctx).Where("email_eq = ?", term).Find(&rows).Error; err != nil { return nil, err } - return gormRowPlan.DecryptAll(ctx, cipher, rows) + return UserRows.DecryptAll(ctx, cipher, rows) } diff --git a/docs/plans/2026-10-04-plan-builder/users/model.go b/docs/plans/2026-10-04-plan-builder/users/model.go index aeb6c79e1..1deb279f5 100644 --- a/docs/plans/2026-10-04-plan-builder/users/model.go +++ b/docs/plans/2026-10-04-plan-builder/users/model.go @@ -1,6 +1,6 @@ package users -//go:generate go tool stashgen -type User +//go:generate go tool stashgen -type User -row UserRows=UserRow -row SQLCUsers=userdb.User // User's tags are the plan; stashgen writes user_stash.go from them. An // exported field with no stash tag is refused, never stored unencrypted. diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index b19291092..b1ccf9d17 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -12,12 +12,6 @@ import ( var ErrNotFound = errors.New("users: not found") -// The sqlc overrides put the same stash tags on userdb.User, so this plan -// checks sqlc's generated struct at startup. A column added without an override -// has no stash tag, and the program panics here instead of storing it as -// plaintext. -var sqlcRowPlan = stackencrypt.MustRowPlan[userdb.User](userPlan.Record()) - // SQLCStore keeps users in Postgres through the queries sqlc generates. type SQLCStore struct { db *sql.DB @@ -34,7 +28,7 @@ func (s *SQLCStore) cipher(tenant string) *stackencrypt.Cipher { } func (s *SQLCStore) Create(ctx context.Context, tenant string, user User) error { - row, err := sqlcRowPlan.Encrypt(ctx, s.cipher(tenant), user) + row, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), user) if err != nil { return fmt.Errorf("encrypt user %d: %w", user.ID, err) } @@ -47,7 +41,7 @@ func (s *SQLCStore) Create(ctx context.Context, tenant string, user User) error // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. func (s *SQLCStore) Import(ctx context.Context, tenant string, people []User) error { - rows, err := sqlcRowPlan.EncryptAll(ctx, s.cipher(tenant), people) + rows, err := SQLCUsers.EncryptAll(ctx, s.cipher(tenant), people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) } @@ -75,7 +69,7 @@ func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, err if err != nil { return User{}, err } - return sqlcRowPlan.Decrypt(ctx, s.cipher(tenant), row) + return SQLCUsers.Decrypt(ctx, s.cipher(tenant), row) } func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { @@ -88,7 +82,7 @@ func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]Us if err != nil { return nil, err } - return sqlcRowPlan.DecryptAll(ctx, cipher, rows) + return SQLCUsers.DecryptAll(ctx, cipher, rows) } func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { @@ -96,7 +90,7 @@ func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { if err != nil { return nil, err } - return sqlcRowPlan.DecryptAll(ctx, s.cipher(tenant), rows) + return SQLCUsers.DecryptAll(ctx, s.cipher(tenant), rows) } // ChangeEmail rewrites one field. The email field owns three columns, and all diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index e124f9f7c..1b82f4946 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -5,18 +5,16 @@ package users import ( "context" + "example.com/app/internal/userdb" "github.com/cipherstash/stack/languages/golang/stackencrypt" ) -// EncryptedUser is a User as stored. It has one field for each field of User -// that the plan stores, and each field holds only the outputs the plan -// declares for it. type EncryptedUser struct { - ID int64 `stash:"id"` - Email EncryptedUserEmail `stash:"email"` - Age EncryptedUserAge `stash:"age"` - Attrs stackencrypt.JSONDocument `stash:"attrs,json"` - Notes stackencrypt.Ciphertext `stash:"notes"` + ID int64 + Email EncryptedUserEmail + Age EncryptedUserAge + Attrs stackencrypt.JSONDocument + Notes stackencrypt.Ciphertext } type EncryptedUserEmail struct { @@ -31,24 +29,80 @@ type EncryptedUserAge struct { Ore stackencrypt.OreTerm } -var userPlan = stackencrypt.MustRowPlan[EncryptedUser](stackencrypt.MustBind[User](stackencrypt.MustPlanOf[User]())) +// Stops compiling when User gains, loses, reorders or retypes a field. +var _ = userShape(User{}) + +type userShape struct { + _ struct{} + ID int64 + Email string + Age uint32 + Attrs map[string]any + Notes string + Internal string +} + +var userPlan = stackencrypt.MustGenerate(stackencrypt.Generated[User, EncryptedUser]{ + Plan: stackencrypt.NewPlan("users"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). + Index("attrs", stackencrypt.JSON()). + Encrypt("notes"). + MustBuild(), + Source: func(v User) stackencrypt.Values { + return stackencrypt.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} + }, + Seal: func(v User, rec stackencrypt.EncryptedRecord) EncryptedUser { + email, age := rec.MustField("email"), rec.MustField("age") + return EncryptedUser{ + ID: v.ID, + Email: EncryptedUserEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, + Age: EncryptedUserAge{Ciphertext: age.Ciphertext, Equality: age.Equality, Ore: age.Ore}, + Attrs: rec.MustField("attrs").JSON, + Notes: rec.MustField("notes").Ciphertext, + } + }, + Open: func(e EncryptedUser) stackencrypt.EncryptedRecord { + return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, + "attrs": {JSON: e.Attrs}, + "notes": {Ciphertext: e.Notes}, + }) + }, + Value: func(e EncryptedUser, vals stackencrypt.Values) (User, error) { + v := User{ID: e.ID} + var err error + if v.Email, err = stackencrypt.Get[string](vals, "email"); err != nil { + return User{}, err + } + if v.Age, err = stackencrypt.Get[uint32](vals, "age"); err != nil { + return User{}, err + } + if v.Attrs, err = stackencrypt.Get[map[string]any](vals, "attrs"); err != nil { + return User{}, err + } + if v.Notes, err = stackencrypt.Get[string](vals, "notes"); err != nil { + return User{}, err + } + return v, nil + }, +}) func (User) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } func (EncryptedUser) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } -// UserFields has one entry for each sealed field of User. An entry has a query -// method only for an index the field declares. var UserFields = struct { Email UserEmailField Age UserAgeField Attrs UserAttrsField Notes UserNotesField }{ - Email: UserEmailField{stackencrypt.MustField[string](userPlan.Record(), "email")}, - Age: UserAgeField{stackencrypt.MustField[uint32](userPlan.Record(), "age")}, - Attrs: UserAttrsField{stackencrypt.MustField[map[string]any](userPlan.Record(), "attrs")}, - Notes: UserNotesField{stackencrypt.MustField[string](userPlan.Record(), "notes")}, + Email: UserEmailField{stackencrypt.MustField[string](userPlan.Plan(), "email")}, + Age: UserAgeField{stackencrypt.MustField[uint32](userPlan.Plan(), "age")}, + Attrs: UserAttrsField{stackencrypt.MustField[map[string]any](userPlan.Plan(), "attrs")}, + Notes: UserNotesField{stackencrypt.MustField[string](userPlan.Plan(), "notes")}, } type UserEmailField struct { @@ -120,3 +174,52 @@ func (f UserNotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v s out, err := f.plan.Encrypt(ctx, c, v, opts...) return out.Ciphertext, err } + +// A row struct converts to and from its shape only while the two have the same +// fields, with the same types, in the same order. A change to the row struct +// stops this file compiling. +type userRowShape struct { + ID int64 + Email stackencrypt.Ciphertext + EmailEq stackencrypt.EqualityTerm + EmailMatch stackencrypt.MatchTerm + Age stackencrypt.Ciphertext + AgeEq stackencrypt.EqualityTerm + AgeOre stackencrypt.OreTerm + Attrs stackencrypt.JSONDocument + Notes stackencrypt.Ciphertext +} + +func userRowShapeOf(e EncryptedUser) userRowShape { + return userRowShape{ + ID: e.ID, + Email: e.Email.Ciphertext, + EmailEq: e.Email.Equality, + EmailMatch: e.Email.Match, + Age: e.Age.Ciphertext, + AgeEq: e.Age.Equality, + AgeOre: e.Age.Ore, + Attrs: e.Attrs, + Notes: e.Notes, + } +} + +func (s userRowShape) encrypted() EncryptedUser { + return EncryptedUser{ + ID: s.ID, + Email: EncryptedUserEmail{Ciphertext: s.Email, Equality: s.EmailEq, Match: s.EmailMatch}, + Age: EncryptedUserAge{Ciphertext: s.Age, Equality: s.AgeEq, Ore: s.AgeOre}, + Attrs: s.Attrs, + Notes: s.Notes, + } +} + +var UserRows = stackencrypt.Rows(userPlan, + func(e EncryptedUser) UserRow { return UserRow(userRowShapeOf(e)) }, + func(r UserRow) EncryptedUser { return userRowShape(r).encrypted() }, +) + +var SQLCUsers = stackencrypt.Rows(userPlan, + func(e EncryptedUser) userdb.User { return userdb.User(userRowShapeOf(e)) }, + func(r userdb.User) EncryptedUser { return userRowShape(r).encrypted() }, +) From 1d67487383178be8aee17862817389ae5809950a Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 13:14:57 +1100 Subject: [PATCH 05/22] docs(plans): run the policy at generate time; no Must for a program to call Design goal: a program that uses the Go binding calls no function whose name starts with Must. The plan states the design; the reasons are here. The policy was the last run-time path. The policy package decides what to encrypt from the data categories a schema gives each field, and its own doc says to run it at startup with MustPlanFor. A policy user called three Must functions (plan.MustPlanFor, MustBind, MustField) and got an EncryptedRecord back, with fields looked up by string. Nothing the policy reads waits for startup: the schema, the categories and the rules are all fixed before the program ships. So the policy now runs during `go generate` and writes the same file the tags give. - A field no rule decides stops `go generate`. It used to stop the process at startup. - A policy change shows in the diff of the generated file. A one-line policy edit used to change which columns are encrypted with nothing in review to show it. - The output is a generated type the compiler checks. Why a generate program the caller owns: the rules are Go values, and a generator cannot evaluate them by reading source. ent (entc) and gqlgen use the same shape. stashgen.Generate is the generator as a library. Why the build constraint: the generate program imports the package that holds the type. A stale generated file fails to compile by design, and that would stop the generate program from building, with no way out. The generated file carries `!stashgen` and the program runs with `-tags stashgen`. This was tested: after a field was added to Individual, `go build ./individuals` failed with "cannot convert" and `go build -tags stashgen ./cmd/genplans` passed. The cost is stated in the plan: code that uses the generated names goes in another package. The tag flow has no such limit, because stashgen loads the package itself and ignores its own output file. Where the rest went. The functions that panic are now reached only by generated code, so they move to a package of their own, stackencrypt/stashrt, and none of them has a Must name. stashrt.New still panics for a plan the engine refuses; the generator writes only plans the engine accepts, so that needs a generated file and a library from two different versions. The public package keeps one plan type, RowPlan. Plan, RecordPlan, Bind, ValuePlan, EncryptedRecord, EncryptedField and PlanError leave the public surface, because nothing a program writes can reach them. The errors section changes to match. A mistake in a plan is found by the generator or the compiler, so an operation returns an error only for a key, the network or stored data. The documents example built its context in a package variable through a helper that panicked. It now returns the error from the call. Every Go file type-checks (`go vet ./...`, and the generate program under `-tags stashgen`) against the uncommitted stub of the proposed API, and gofmt reports nothing. No example calls a Must function. The four _stash.go files are written by hand as the files stashgen will write. The amended text passes slipstream's language, length and line-break checks with 0 findings (155 sentences, the longest 24 words), and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 112 +++++++------ docs/plans/2026-10-04-plan-builder/README.md | 7 +- .../blocklist/blocked_stash.go | 32 ++-- .../cmd/genplans/main.go | 15 ++ .../contacts/contactstash_stash.go | 40 ++--- .../documents/documents.go | 22 ++- .../individuals/individual.go | 14 ++ .../individuals/individual_stash.go | 148 ++++++++++++++++++ .../individuals/individuals.go | 96 ------------ .../individualstore/store.go | 33 ++++ .../2026-10-04-plan-builder/policy/policy.go | 40 +++++ .../users/user_stash.go | 64 ++++---- 12 files changed, 404 insertions(+), 219 deletions(-) create mode 100644 docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go create mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individual.go create mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go delete mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individuals.go create mode 100644 docs/plans/2026-10-04-plan-builder/individualstore/store.go create mode 100644 docs/plans/2026-10-04-plan-builder/policy/policy.go diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index efe9292fa..acb3eb23c 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -570,6 +570,7 @@ It takes these flags: `stashgen` loads the package with `golang.org/x/tools/go/packages` and reads types, not text. It runs none of the package's code. +It ignores its own output file when it loads the package, so a stale file does not stop it. The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. #### What stashgen writes @@ -581,7 +582,7 @@ For `-type User`, the file `user_stash.go` holds: A field with one output has that output's type, such as `Ciphertext`. A field with more outputs has its own struct, such as `EncryptedUserEmail`, with one field for each output. - **The plan.** - The file builds it with the builder at package init, and converts values with plain assignments. + The file declares it as data, and converts values with plain assignments. Generated code uses no reflection. - **A `StashPlan` method on `User` and on `EncryptedUser`.** The method gives each type the `Planned` interface. @@ -654,6 +655,37 @@ It writes `EncryptedContact`, `ContactFields` and an exported `ContactPlan`, a ` The file converts `crm.Contact` to a copy of its fields, so a change to `crm.Contact` stops the build. See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). +#### Plans from a policy + +A policy decides a plan from the data categories that a schema gives each field. +The policy is Go code, so a program must run it. +That program is a generate program that you own, and `go generate` runs it: + +```go +//go:generate go run -tags stashgen ../cmd/genplans + +func main() { + err := stashgen.Generate(policy.Source, policy.Individuals, stashgen.Output("individual_stash.go")) + if err != nil { + log.Fatal(err) + } +} +``` + +`stashgen.Generate` is the generator as a library, at `languages/golang/stashgen`. +It runs the policy over the facts and writes the same file that the tags give. +The application never runs the policy. + +- A field that no rule decides stops `go generate`, with the field's name and its annotations. +- A change to the policy changes the generated file, so a reviewer reads what the change encrypts. +- A field that the policy stores as plaintext is a plain field of the generated type. + +The generate program imports the package that holds the type. +So that package must build when the generated file is stale. +The generated file carries the build constraint `!stashgen`, and the generate program runs with `-tags stashgen`. +Code that uses the generated names goes in another package. +See [`policy/policy.go`](2026-10-04-plan-builder/policy/policy.go), [`cmd/genplans/main.go`](2026-10-04-plan-builder/cmd/genplans/main.go) and [`individualstore/store.go`](2026-10-04-plan-builder/individualstore/store.go). + #### When the compiler finds a mistake | Mistake | Found by | @@ -664,55 +696,44 @@ See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). | A change to a storage struct, or to a type in another package | the compiler | | A field added to a struct from another package that has an unexported field | CI | | A field added to, removed from or retyped in the tagged struct | the compiler | -| A change to a tag only, with no `go generate` run | CI | +| A tag that does not parse, or a field that no policy rule decides | `go generate` | +| A change to a tag or to a policy, with no `go generate` run | CI | CI runs `go generate ./...` and fails when a generated file differs from the committed file. The compiler does not make those two checks. ### Plan types -- `Plan` is the untyped data form that the guest receives. - The builder makes one. -- `RowPlan[T, R]` encrypts a `T` into an `R` and decrypts an `R` into a `T`. - Only generated code makes one. -- `ValuePlan[V]` is the plan for one field, with the field's Go type `V`. - `Field[V]` takes one from a `Plan`. -- `RecordPlan[T]` is a `Plan` that `Bind[T]` binds to a struct type at run time. - Only the policy package needs it. - +`RowPlan[T, R]` is the one plan type in `stackencrypt`. +It encrypts a `T` into an `R` and decrypts an `R` into a `T`. +Only generated code makes one. +A program reaches it through `stackencrypt.Encrypt`, or through a generated variable such as `UserRows`. A plan does not change after it is built, and any number of goroutines can use it at the same time. -### The builder - -`NewPlan` makes a `Plan`, and generated code and the policy package call it: - -```go -plan, err := stackencrypt.NewPlan("contacts"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("phone_number", stackencrypt.Equality). - Build() -``` - -`EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. -Each method returns a new builder and does not change the builder it is called on. -`Build` checks the whole-plan rules and returns an error, and `MustBuild` panics. +No function that a program calls has a name that starts with `Must`. +No function panics for a mistake in a plan that a program wrote. ### Support for generated code -Generated code calls four things that other code does not need: +The package `stackencrypt/stashrt` holds what only generated code calls: -- `Generated[T, E]` holds the plan and four conversion functions, and `MustGenerate` makes a `RowPlan[T, E]` from it. +- `Plan` declares the fields and their indexes as data. + `EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. +- `Generated[T, E]` holds the plan and four conversion functions, and `New` makes a `RowPlan[T, E]` from it. - `Rows` makes a `RowPlan[T, R]` for a storage struct from a generated plan and two conversions. +- `Field[V]` is the plan for one field, and a generated field entry holds one. - `Values` holds plaintext values by field name, and `Get[V]` reads one as the Go type `V`. -- `RecordOf` and `EncryptedRecord.MustField` build and read an `EncryptedRecord`. +- `Record` holds the outputs of each field by name. + +`New` panics for a plan that the engine refuses. +The generator writes only plans that the engine accepts. +So that panic needs a generated file and a library from two different versions. ### The policy package -`plan.PlanFor` makes a `Plan` from facts and a policy at run time, and `plan.EQL` takes `stackencrypt.Index` values. -`Bind[T]` binds that plan to a struct type, and `RecordPlan[T].Encrypt` returns an `EncryptedRecord`. -This is the one path that the program checks at run time, because the policy decides the plan when the program starts. -`Bind` and `EncryptedRecord.Field` return an error for a field that does not match. -See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individuals.go). +`plan.Policy`, `plan.When`, `plan.FirstOf` and `plan.ForMessage` keep their form. +`plan.EQL` takes `stackencrypt.Index` values. +`stashgen.Generate` runs the policy, as "Plans from a policy" describes. ### Indexes @@ -729,7 +750,6 @@ See [`individuals/individuals.go`](2026-10-04-plan-builder/individuals/individua | `Decrypt(ctx, d Decrypter, e E, opts ...Option)` | `T` | | `DecryptAll(ctx, d Decrypter, es []E, opts ...Option)` | `[]T` | | `RowPlan[T, R]`: the same four, as methods | `R` in place of `E` | -| `RecordPlan[T]`: the same four, as methods | `EncryptedRecord` in place of `E` | | A generated field entry, such as `UserFields.Email`: `Encrypt` | the field's generated type | | A generated field entry: `Equality`, `Match`, `Ore`, `Ope` | `EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` | | A generated field entry: `Contains`, `EqualAt` | `JSONQuery` | @@ -760,22 +780,16 @@ See [`users/extend.go`](2026-10-04-plan-builder/users/extend.go). `Ciphertext`, each term type and `JSONDocument` also implement `sql.Scanner`. So a field of a generated type, or of a storage struct, goes straight to `ExecContext` and to `Scan`. -`EncryptedRecord` is the output of the policy path. -`EncryptedRecord.Field(name)` returns the named field's `EncryptedField`. -For a name that the plan does not have, it returns `ErrUnknownField` and never a zero value. - ### Errors -`Build`, `Bind`, `Field` and every operation return a `*PlanError`. -A `*PlanError` names the field and wraps one rule: +The generator and the compiler find a mistake in a plan, so no operation returns an error for one. +An operation returns an error for a key, for the network, or for stored data: -- `ErrUnknownField`: the plan has no field with that name. -- `ErrMissingField`: a plan field is not in the value. -- `ErrUnplannedField`: the value has a field that the plan does not name. -- `ErrIndexNotDeclared`: the field does not declare that index. -- `ErrIndexType`: the index does not apply to the field's type. +- `ErrForeignKeyset`: a `*Cipher` got a leaf that another keyset sealed. +- A ciphertext that does not decrypt under the plan's context. +- A decrypted value that is not the field's Go type. -Use `errors.As` and `errors.Is` to read it. +Use `errors.Is` and `errors.As` to read it. No error holds a plaintext value. ### Databases and ORMs @@ -798,11 +812,13 @@ The binding has never been released, so these are removed, not deprecated: - `Cipher.Decrypt` and `Client.Decrypt`: `DecryptValue[T]` replaces them. - `EncryptElement` and `DecryptElement`. - `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. +- `EncryptedRecord` and `EncryptedField`: a generated type replaces them. - `Cipher.Term`: the query methods of a generated field entry replace it. - `RecordOption` and `WithPlan`. - `TermKind`: `Index` replaces it. -- `FieldPlan`, `NewPlan(fields ...FieldPlan)` and `Plan.Validate`: the builder and `Bind[T]` replace them. -- `PlanFromTags`, and every other function that reads `stash` tags at run time: `stashgen` replaces them. +- `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them. +- `PlanFromTags`, and every other function that reads `stash` tags at run time: the generator replaces them. +- `plan.PlanFor` and `plan.MustPlanFor`: `stashgen.Generate` replaces them. - The rule that a zero `Plan` means the struct's tags. - `Sealed`, `SealedNone`, `SealedEmptyMap` and `SealedEmptySeq` as storage types: `Ciphertext` replaces them. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index 0e71894ed..f27244c6b 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -17,14 +17,17 @@ The module path is `example.com/app`. | [`users/extend.go`](users/extend.go) | A context extension on the write, the query and the read | | [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | | [`contacts/contacts.go`](contacts/contacts.go) | A plan for [`crm.Contact`](crm/contact.go), a type in another package, with its generated file [`contactstash_stash.go`](contacts/contactstash_stash.go) | -| [`individuals/individuals.go`](individuals/individuals.go) | A plan from the policy package, which the program checks at run time | +| [`policy/policy.go`](policy/policy.go) | A policy that decides what to encrypt from each field's data categories | +| [`cmd/genplans/main.go`](cmd/genplans/main.go) | The generate program that runs the policy | +| [`individuals/`](individuals/) | A type with no tags, and [`individual_stash.go`](individuals/individual_stash.go), the file the policy gives | +| [`individualstore/store.go`](individualstore/store.go) | A store that uses the generated type | | [`blocklist/blocklist.go`](blocklist/blocklist.go) | One value with no record around it, as a struct with one field, with its generated file [`blocked_stash.go`](blocklist/blocked_stash.go) | | [`documents/documents.go`](documents/documents.go) | One value sealed as one tree, decrypted with the client | | [`eql-sqlc/`](eql-sqlc/) | sqlc with EQL v3 domain columns, which generates [`internal/eqldb/`](internal/eqldb/) | ## Generate the encrypted type -`stashgen` does not exist yet, so the three `_stash.go` files are written by hand as the files it will write. +`stashgen` does not exist yet, so the four `_stash.go` files are written by hand as the files it will write. ## Generate the sqlc packages diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go index 2da842775..8837748d0 100644 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go @@ -6,6 +6,7 @@ import ( "context" "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" ) type EncryptedBlocked struct { @@ -25,24 +26,25 @@ type blockedShape struct { Email string } -var blockedPlan = stackencrypt.MustGenerate(stackencrypt.Generated[Blocked, EncryptedBlocked]{ - Plan: stackencrypt.NewPlan("blocked_emails"). - EncryptIndex("email", stackencrypt.Equality). - MustBuild(), - Source: func(v Blocked) stackencrypt.Values { - return stackencrypt.Values{"email": v.Email} +var blockedSpec = stashrt.NewPlan("blocked_emails"). + EncryptIndex("email", stackencrypt.Equality) + +var blockedPlan = stashrt.New(stashrt.Generated[Blocked, EncryptedBlocked]{ + Plan: blockedSpec, + Source: func(v Blocked) stashrt.Values { + return stashrt.Values{"email": v.Email} }, - Seal: func(v Blocked, rec stackencrypt.EncryptedRecord) EncryptedBlocked { - email := rec.MustField("email") + Seal: func(v Blocked, rec stashrt.Record) EncryptedBlocked { + email := rec["email"] return EncryptedBlocked{Email: EncryptedBlockedEmail{Ciphertext: email.Ciphertext, Equality: email.Equality}} }, - Open: func(e EncryptedBlocked) stackencrypt.EncryptedRecord { - return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + Open: func(e EncryptedBlocked) stashrt.Record { + return stashrt.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality}, - }) + } }, - Value: func(e EncryptedBlocked, vals stackencrypt.Values) (Blocked, error) { - email, err := stackencrypt.Get[string](vals, "email") + Value: func(e EncryptedBlocked, vals stashrt.Values) (Blocked, error) { + email, err := stashrt.Get[string](vals, "email") if err != nil { return Blocked{}, err } @@ -59,11 +61,11 @@ func (EncryptedBlocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBloc var BlockedFields = struct { Email BlockedEmailField }{ - Email: BlockedEmailField{stackencrypt.MustField[string](blockedPlan.Plan(), "email")}, + Email: BlockedEmailField{stashrt.NewField[string](blockedSpec, "email")}, } type BlockedEmailField struct { - plan *stackencrypt.ValuePlan[string] + plan stashrt.Field[string] } func (f BlockedEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedBlockedEmail, error) { diff --git a/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go b/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go new file mode 100644 index 000000000..904b37941 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go @@ -0,0 +1,15 @@ +// Command genplans runs the policy at go generate time. +package main + +import ( + "log" + + "example.com/app/policy" + "github.com/cipherstash/stack/languages/golang/stashgen" +) + +func main() { + if err := stashgen.Generate(policy.Source, policy.Individuals, stashgen.Output("individual_stash.go")); err != nil { + log.Fatal(err) + } +} diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index a82a8d02e..b2f1cdf48 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -7,6 +7,7 @@ import ( "example.com/app/crm" "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" ) type EncryptedContact struct { @@ -38,35 +39,36 @@ type contactShape struct { // crm.Contact is not this package's type, so it cannot have a StashPlan // method. Call the plan's methods. -var ContactPlan = stackencrypt.MustGenerate(stackencrypt.Generated[crm.Contact, EncryptedContact]{ - Plan: stackencrypt.NewPlan("contacts"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("phone_number", stackencrypt.Equality). - MustBuild(), - Source: func(v crm.Contact) stackencrypt.Values { - return stackencrypt.Values{"email": v.Email, "phone_number": v.PhoneNumber} +var contactSpec = stashrt.NewPlan("contacts"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("phone_number", stackencrypt.Equality) + +var ContactPlan = stashrt.New(stashrt.Generated[crm.Contact, EncryptedContact]{ + Plan: contactSpec, + Source: func(v crm.Contact) stashrt.Values { + return stashrt.Values{"email": v.Email, "phone_number": v.PhoneNumber} }, - Seal: func(v crm.Contact, rec stackencrypt.EncryptedRecord) EncryptedContact { - email, phone := rec.MustField("email"), rec.MustField("phone_number") + Seal: func(v crm.Contact, rec stashrt.Record) EncryptedContact { + email, phone := rec["email"], rec["phone_number"] return EncryptedContact{ ID: v.ID, Email: EncryptedContactEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, } }, - Open: func(e EncryptedContact) stackencrypt.EncryptedRecord { - return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + Open: func(e EncryptedContact) stashrt.Record { + return stashrt.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, - }) + } }, - Value: func(e EncryptedContact, vals stackencrypt.Values) (crm.Contact, error) { + Value: func(e EncryptedContact, vals stashrt.Values) (crm.Contact, error) { v := crm.Contact{ID: e.ID} var err error - if v.Email, err = stackencrypt.Get[string](vals, "email"); err != nil { + if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { return crm.Contact{}, err } - if v.PhoneNumber, err = stackencrypt.Get[string](vals, "phone_number"); err != nil { + if v.PhoneNumber, err = stashrt.Get[string](vals, "phone_number"); err != nil { return crm.Contact{}, err } return v, nil @@ -77,12 +79,12 @@ var ContactFields = struct { Email ContactEmailField PhoneNumber ContactPhoneNumberField }{ - Email: ContactEmailField{stackencrypt.MustField[string](ContactPlan.Plan(), "email")}, - PhoneNumber: ContactPhoneNumberField{stackencrypt.MustField[string](ContactPlan.Plan(), "phone_number")}, + Email: ContactEmailField{stashrt.NewField[string](contactSpec, "email")}, + PhoneNumber: ContactPhoneNumberField{stashrt.NewField[string](contactSpec, "phone_number")}, } type ContactEmailField struct { - plan *stackencrypt.ValuePlan[string] + plan stashrt.Field[string] } func (f ContactEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactEmail, error) { @@ -102,7 +104,7 @@ func (f ContactEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v } type ContactPhoneNumberField struct { - plan *stackencrypt.ValuePlan[string] + plan stashrt.Field[string] } func (f ContactPhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactPhoneNumber, error) { diff --git a/docs/plans/2026-10-04-plan-builder/documents/documents.go b/docs/plans/2026-10-04-plan-builder/documents/documents.go index 92ab021b0..4a78e98c7 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/documents.go +++ b/docs/plans/2026-10-04-plan-builder/documents/documents.go @@ -15,18 +15,20 @@ type Document struct { Tags []string } -var bodyContext = mustLabel("documents", "v2", "body").Context() - -func mustLabel(segments ...string) stackencrypt.Label { - label, err := stackencrypt.NewLabel(segments...) +func bodyContext() (stackencrypt.Context, error) { + label, err := stackencrypt.NewLabel("documents", "v2", "body") if err != nil { - panic(err) + return stackencrypt.Context{}, err } - return label + return label.Context(), nil } func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64, doc Document) error { - sealed, err := cipher.Encrypt(ctx, doc, bodyContext) + body, err := bodyContext() + if err != nil { + return err + } + sealed, err := cipher.Encrypt(ctx, doc, body) if err != nil { return err } @@ -38,9 +40,13 @@ func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64 // sealed it. A *stackencrypt.Cipher would also refuse a leaf from another // keyset. func Load(ctx context.Context, db *sql.DB, client *stackencrypt.Client, id int64) (Document, error) { + body, err := bodyContext() + if err != nil { + return Document{}, err + } var sealed stackencrypt.Ciphertext if err := db.QueryRowContext(ctx, `SELECT body FROM documents WHERE id = $1`, id).Scan(&sealed); err != nil { return Document{}, err } - return stackencrypt.DecryptValue[Document](ctx, client, sealed, bodyContext) + return stackencrypt.DecryptValue[Document](ctx, client, sealed, body) } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual.go b/docs/plans/2026-10-04-plan-builder/individuals/individual.go new file mode 100644 index 000000000..0120c907a --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual.go @@ -0,0 +1,14 @@ +// Package individuals must build without individual_stash.go, because the +// generate program imports it. Code that uses the generated names lives in +// individualstore. +package individuals + +//go:generate go run -tags stashgen ../cmd/genplans + +type Individual struct { + ID int64 + Name string + Email string + MedicareNo string + Nickname string +} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go new file mode 100644 index 000000000..dbde943e9 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -0,0 +1,148 @@ +// Code generated by stashgen. DO NOT EDIT. + +//go:build !stashgen + +package individuals + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" +) + +type EncryptedIndividual struct { + ID int64 + Name stackencrypt.Ciphertext + Email EncryptedIndividualEmail + MedicareNo EncryptedIndividualMedicareNo + Nickname string +} + +type EncryptedIndividualEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Match stackencrypt.MatchTerm +} + +type EncryptedIndividualMedicareNo struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm +} + +// Stops compiling when Individual gains, loses, reorders or retypes a field. +var _ = individualShape(Individual{}) + +type individualShape struct { + ID int64 + Name string + Email string + MedicareNo string + Nickname string +} + +var individualSpec = stashrt.NewPlan("individuals"). + Encrypt("name"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("medicare_number", stackencrypt.Equality) + +var individualPlan = stashrt.New(stashrt.Generated[Individual, EncryptedIndividual]{ + Plan: individualSpec, + Source: func(v Individual) stashrt.Values { + return stashrt.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} + }, + Seal: func(v Individual, rec stashrt.Record) EncryptedIndividual { + email, medicare := rec["email"], rec["medicare_number"] + return EncryptedIndividual{ + ID: v.ID, + Name: rec["name"].Ciphertext, + Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, + MedicareNo: EncryptedIndividualMedicareNo{Ciphertext: medicare.Ciphertext, Equality: medicare.Equality}, + Nickname: v.Nickname, + } + }, + Open: func(e EncryptedIndividual) stashrt.Record { + return stashrt.Record{ + "name": {Ciphertext: e.Name}, + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "medicare_number": {Ciphertext: e.MedicareNo.Ciphertext, Equality: e.MedicareNo.Equality}, + } + }, + Value: func(e EncryptedIndividual, vals stashrt.Values) (Individual, error) { + v := Individual{ID: e.ID, Nickname: e.Nickname} + var err error + if v.Name, err = stashrt.Get[string](vals, "name"); err != nil { + return Individual{}, err + } + if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { + return Individual{}, err + } + if v.MedicareNo, err = stashrt.Get[string](vals, "medicare_number"); err != nil { + return Individual{}, err + } + return v, nil + }, +}) + +func (Individual) StashPlan() *stackencrypt.RowPlan[Individual, EncryptedIndividual] { + return individualPlan +} + +func (EncryptedIndividual) StashPlan() *stackencrypt.RowPlan[Individual, EncryptedIndividual] { + return individualPlan +} + +var IndividualFields = struct { + Name IndividualNameField + Email IndividualEmailField + MedicareNo IndividualMedicareNoField +}{ + Name: IndividualNameField{stashrt.NewField[string](individualSpec, "name")}, + Email: IndividualEmailField{stashrt.NewField[string](individualSpec, "email")}, + MedicareNo: IndividualMedicareNoField{stashrt.NewField[string](individualSpec, "medicare_number")}, +} + +type IndividualNameField struct { + plan stashrt.Field[string] +} + +func (f IndividualNameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + return out.Ciphertext, err +} + +type IndividualEmailField struct { + plan stashrt.Field[string] +} + +func (f IndividualEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualEmail, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedIndividualEmail{}, err + } + return EncryptedIndividualEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil +} + +func (f IndividualEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} + +func (f IndividualEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { + return f.plan.Match(ctx, c, v, opts...) +} + +type IndividualMedicareNoField struct { + plan stashrt.Field[string] +} + +func (f IndividualMedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualMedicareNo, error) { + out, err := f.plan.Encrypt(ctx, c, v, opts...) + if err != nil { + return EncryptedIndividualMedicareNo{}, err + } + return EncryptedIndividualMedicareNo{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f IndividualMedicareNoField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { + return f.plan.Equality(ctx, c, v, opts...) +} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individuals.go b/docs/plans/2026-10-04-plan-builder/individuals/individuals.go deleted file mode 100644 index 8010b94ed..000000000 --- a/docs/plans/2026-10-04-plan-builder/individuals/individuals.go +++ /dev/null @@ -1,96 +0,0 @@ -// Package individuals stores a type that carries no stash tags. A policy -// decides each field from the data categories the schema gives it. -package individuals - -import ( - "context" - "database/sql" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" -) - -// Individual stands in for a type generated from a schema, such as a protobuf -// message, that cannot carry tags. -type Individual struct { - ID int64 - Name string - Email string - MedicareNo string - Nickname string -} - -var category = plan.Key("fides.data_categories") - -func categories(values ...string) []plan.Annotation { - return []plan.Annotation{{Key: string(category), Values: values}} -} - -// source states the facts a schema reader would produce. A protobuf source -// reads the same facts from descriptors and their custom options. -var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { - return []plan.Fact{ - {Field: "id", GoField: "ID"}, - {Field: "name", GoField: "Name", Annotations: categories("user.name")}, - {Field: "email", GoField: "Email", Annotations: categories("user.contact.email")}, - {Field: "medicare_no", GoField: "MedicareNo", Annotations: categories("user.government_id")}, - {Field: "nickname", GoField: "Nickname"}, - }, nil -}) - -var base = plan.FirstOf( - plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), - plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match()))), - plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), -) - -var individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), - plan.FirstOf( - plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), - plan.Column("medicare_number")), - ).OrElse(base), -) - -// The policy stores id and nickname as plaintext. The plan names them as -// omitted fields, so Bind accepts Individual. -var ( - individualsPlan = stackencrypt.MustBind[Individual](plan.MustPlanFor(source, individuals)) - medicarePlan = stackencrypt.MustField[string](individualsPlan.Plan(), "medicare_number") -) - -func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person Individual) error { - record, err := individualsPlan.Encrypt(ctx, cipher, person) - if err != nil { - return err - } - // Field returns an error for a name the plan does not have, never a zero - // value that would write NULL. - name, err := record.Field("name") - if err != nil { - return err - } - email, err := record.Field("email") - if err != nil { - return err - } - medicare, err := record.Field("medicare_number") - if err != nil { - return err - } - _, err = db.ExecContext(ctx, ` - INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - person.ID, person.Nickname, name.Ciphertext, email.Ciphertext, email.Equality, email.Match, - medicare.Ciphertext, medicare.Equality) - return err -} - -func IDByMedicare(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, medicareNo string) (int64, error) { - term, err := medicarePlan.Equality(ctx, cipher, medicareNo) - if err != nil { - return 0, err - } - var id int64 - err = db.QueryRowContext(ctx, `SELECT id FROM individuals WHERE medicare_number_eq = $1`, term).Scan(&id) - return id, err -} diff --git a/docs/plans/2026-10-04-plan-builder/individualstore/store.go b/docs/plans/2026-10-04-plan-builder/individualstore/store.go new file mode 100644 index 000000000..0729a430d --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individualstore/store.go @@ -0,0 +1,33 @@ +// Package individualstore keeps individuals in Postgres. +package individualstore + +import ( + "context" + "database/sql" + + "example.com/app/individuals" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person individuals.Individual) error { + enc, err := stackencrypt.Encrypt(ctx, cipher, person) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, ` + INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + enc.ID, enc.Nickname, enc.Name, enc.Email.Ciphertext, enc.Email.Equality, enc.Email.Match, + enc.MedicareNo.Ciphertext, enc.MedicareNo.Equality) + return err +} + +func IDByMedicare(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, medicareNo string) (int64, error) { + term, err := individuals.IndividualFields.MedicareNo.Equality(ctx, cipher, medicareNo) + if err != nil { + return 0, err + } + var id int64 + err = db.QueryRowContext(ctx, `SELECT id FROM individuals WHERE medicare_number_eq = $1`, term).Scan(&id) + return id, err +} diff --git a/docs/plans/2026-10-04-plan-builder/policy/policy.go b/docs/plans/2026-10-04-plan-builder/policy/policy.go new file mode 100644 index 000000000..d591b0d60 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/policy/policy.go @@ -0,0 +1,40 @@ +// Package policy decides what to encrypt from the data categories a schema +// gives each field. It runs in the generate program, never in the application. +package policy + +import ( + "example.com/app/individuals" + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +var category = plan.Key("fides.data_categories") + +func categories(values ...string) []plan.Annotation { + return []plan.Annotation{{Key: string(category), Values: values}} +} + +// Source states the facts a schema reader would produce. A protobuf source +// reads the same facts from descriptors and their custom options. +var Source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { + return []plan.Fact{ + {Field: "id", GoField: "ID"}, + {Field: "name", GoField: "Name", Annotations: categories("user.name")}, + {Field: "email", GoField: "Email", Annotations: categories("user.contact.email")}, + {Field: "medicare_no", GoField: "MedicareNo", Annotations: categories("user.government_id")}, + {Field: "nickname", GoField: "Nickname"}, + }, nil +}) + +var Base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match()))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +var Individuals = plan.ForMessage(individuals.Individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_number")), + ).OrElse(Base), +) diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index 1b82f4946..c508322d3 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -7,6 +7,7 @@ import ( "example.com/app/internal/userdb" "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" ) type EncryptedUser struct { @@ -42,47 +43,48 @@ type userShape struct { Internal string } -var userPlan = stackencrypt.MustGenerate(stackencrypt.Generated[User, EncryptedUser]{ - Plan: stackencrypt.NewPlan("users"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). - Index("attrs", stackencrypt.JSON()). - Encrypt("notes"). - MustBuild(), - Source: func(v User) stackencrypt.Values { - return stackencrypt.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} +var userSpec = stashrt.NewPlan("users"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). + Index("attrs", stackencrypt.JSON()). + Encrypt("notes") + +var userPlan = stashrt.New(stashrt.Generated[User, EncryptedUser]{ + Plan: userSpec, + Source: func(v User) stashrt.Values { + return stashrt.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} }, - Seal: func(v User, rec stackencrypt.EncryptedRecord) EncryptedUser { - email, age := rec.MustField("email"), rec.MustField("age") + Seal: func(v User, rec stashrt.Record) EncryptedUser { + email, age := rec["email"], rec["age"] return EncryptedUser{ ID: v.ID, Email: EncryptedUserEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, Age: EncryptedUserAge{Ciphertext: age.Ciphertext, Equality: age.Equality, Ore: age.Ore}, - Attrs: rec.MustField("attrs").JSON, - Notes: rec.MustField("notes").Ciphertext, + Attrs: rec["attrs"].JSON, + Notes: rec["notes"].Ciphertext, } }, - Open: func(e EncryptedUser) stackencrypt.EncryptedRecord { - return stackencrypt.RecordOf(map[string]stackencrypt.EncryptedField{ + Open: func(e EncryptedUser) stashrt.Record { + return stashrt.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, "attrs": {JSON: e.Attrs}, "notes": {Ciphertext: e.Notes}, - }) + } }, - Value: func(e EncryptedUser, vals stackencrypt.Values) (User, error) { + Value: func(e EncryptedUser, vals stashrt.Values) (User, error) { v := User{ID: e.ID} var err error - if v.Email, err = stackencrypt.Get[string](vals, "email"); err != nil { + if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { return User{}, err } - if v.Age, err = stackencrypt.Get[uint32](vals, "age"); err != nil { + if v.Age, err = stashrt.Get[uint32](vals, "age"); err != nil { return User{}, err } - if v.Attrs, err = stackencrypt.Get[map[string]any](vals, "attrs"); err != nil { + if v.Attrs, err = stashrt.Get[map[string]any](vals, "attrs"); err != nil { return User{}, err } - if v.Notes, err = stackencrypt.Get[string](vals, "notes"); err != nil { + if v.Notes, err = stashrt.Get[string](vals, "notes"); err != nil { return User{}, err } return v, nil @@ -99,14 +101,14 @@ var UserFields = struct { Attrs UserAttrsField Notes UserNotesField }{ - Email: UserEmailField{stackencrypt.MustField[string](userPlan.Plan(), "email")}, - Age: UserAgeField{stackencrypt.MustField[uint32](userPlan.Plan(), "age")}, - Attrs: UserAttrsField{stackencrypt.MustField[map[string]any](userPlan.Plan(), "attrs")}, - Notes: UserNotesField{stackencrypt.MustField[string](userPlan.Plan(), "notes")}, + Email: UserEmailField{stashrt.NewField[string](userSpec, "email")}, + Age: UserAgeField{stashrt.NewField[uint32](userSpec, "age")}, + Attrs: UserAttrsField{stashrt.NewField[map[string]any](userSpec, "attrs")}, + Notes: UserNotesField{stashrt.NewField[string](userSpec, "notes")}, } type UserEmailField struct { - plan *stackencrypt.ValuePlan[string] + plan stashrt.Field[string] } func (f UserEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedUserEmail, error) { @@ -126,7 +128,7 @@ func (f UserEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v str } type UserAgeField struct { - plan *stackencrypt.ValuePlan[uint32] + plan stashrt.Field[uint32] } func (f UserAgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (EncryptedUserAge, error) { @@ -146,7 +148,7 @@ func (f UserAgeField) Ore(ctx context.Context, c *stackencrypt.Cipher, v uint32, } type UserAttrsField struct { - plan *stackencrypt.ValuePlan[map[string]any] + plan stashrt.Field[map[string]any] } func (f UserAttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONDocument, error) { @@ -167,7 +169,7 @@ func (f UserAttrsField) EqualAt(ctx context.Context, c *stackencrypt.Cipher, pat } type UserNotesField struct { - plan *stackencrypt.ValuePlan[string] + plan stashrt.Field[string] } func (f UserNotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { @@ -214,12 +216,12 @@ func (s userRowShape) encrypted() EncryptedUser { } } -var UserRows = stackencrypt.Rows(userPlan, +var UserRows = stashrt.Rows(userPlan, func(e EncryptedUser) UserRow { return UserRow(userRowShapeOf(e)) }, func(r UserRow) EncryptedUser { return userRowShape(r).encrypted() }, ) -var SQLCUsers = stackencrypt.Rows(userPlan, +var SQLCUsers = stashrt.Rows(userPlan, func(e EncryptedUser) userdb.User { return userdb.User(userRowShapeOf(e)) }, func(r userdb.User) EncryptedUser { return userRowShape(r).encrypted() }, ) From b946e2c83d4a6596f4b22e10dace1b02ef74d35d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 13:24:39 +1100 Subject: [PATCH 06/22] docs(plans): no panic left in the Go binding; stashrt becomes gensupport Two changes to the support package for generated code. The plan states the design; the reasons are here. The last panic. The package's constructor panicked when the engine refused a plan. The generator writes only plans the engine accepts, so the one way to reach that panic was a generated file from one version running against a library from another. It was a panic only because a package-level variable has nowhere to return an error. It is removed in two steps: - The version mismatch moves to compile time. Each generated file names a constant that only a compatible library declares, so a file from another version does not compile. protobuf (protoimpl.EnforceVersion) and gRPC (SupportPackageIsVersion9) do the same in their generated code. Tested: a generated file that names a version the library does not declare fails `go build` with "undefined: gensupport.GeneratedVersion2". - The constructor only stores what it is given, so it has nothing to fail on. The engine still checks a plan when an operation first sends it to the guest, and that operation already returns an error. A version mismatch is now a build failure. That fits the rule this design follows better than a panic at startup did. The name. stashrt stood for "stash runtime". `rt` is an abbreviation a reader has to guess, "runtime" is the wrong word in a design that moves checks ahead of run time, and the name did not say the one thing a reader needs: only generated code imports this. gensupport says it, and follows protobuf's runtime/protoimpl and the Google API clients' gensupport. It cannot live under internal/, because Go would then stop a caller's generated file from importing it. Every Go file type-checks (`go vet ./...`, and the generate program under `-tags stashgen`) against the uncommitted stub of the proposed API, and gofmt reports nothing. The amended text passes slipstream's language, length and line-break checks with 0 findings (158 sentences, the longest 24 words), and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 15 ++++-- .../blocklist/blocked_stash.go | 27 +++++----- .../contacts/contactstash_stash.go | 33 +++++++------ .../individuals/individual_stash.go | 39 ++++++++------- .../users/user_stash.go | 49 ++++++++++--------- 5 files changed, 90 insertions(+), 73 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index acb3eb23c..31fe0c1ee 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -697,6 +697,7 @@ See [`policy/policy.go`](2026-10-04-plan-builder/policy/policy.go), [`cmd/genpla | A field added to a struct from another package that has an unexported field | CI | | A field added to, removed from or retyped in the tagged struct | the compiler | | A tag that does not parse, or a field that no policy rule decides | `go generate` | +| A generated file and a library from versions that do not agree | the compiler | | A change to a tag or to a policy, with no `go generate` run | CI | CI runs `go generate ./...` and fails when a generated file differs from the committed file. @@ -711,11 +712,11 @@ A program reaches it through `stackencrypt.Encrypt`, or through a generated vari A plan does not change after it is built, and any number of goroutines can use it at the same time. No function that a program calls has a name that starts with `Must`. -No function panics for a mistake in a plan that a program wrote. +No function in the binding panics for a plan, in generated code or in any other code. ### Support for generated code -The package `stackencrypt/stashrt` holds what only generated code calls: +The package `stackencrypt/gensupport` holds what only generated code calls: - `Plan` declares the fields and their indexes as data. `EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. @@ -725,9 +726,13 @@ The package `stackencrypt/stashrt` holds what only generated code calls: - `Values` holds plaintext values by field name, and `Get[V]` reads one as the Go type `V`. - `Record` holds the outputs of each field by name. -`New` panics for a plan that the engine refuses. -The generator writes only plans that the engine accepts. -So that panic needs a generated file and a library from two different versions. +No function in this package panics. +`New` only stores what it is given. +The engine checks a plan when an operation first sends it to the guest, and that operation returns the error. + +A generated file and the library must be from versions that agree. +Each generated file names a constant, such as `gensupport.GeneratedVersion1`, that only such a library declares. +So a file from another version does not compile. ### The policy package diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go index 8837748d0..5a63f308d 100644 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go @@ -6,9 +6,12 @@ import ( "context" "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" ) +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + type EncryptedBlocked struct { Email EncryptedBlockedEmail } @@ -26,25 +29,25 @@ type blockedShape struct { Email string } -var blockedSpec = stashrt.NewPlan("blocked_emails"). +var blockedSpec = gensupport.NewPlan("blocked_emails"). EncryptIndex("email", stackencrypt.Equality) -var blockedPlan = stashrt.New(stashrt.Generated[Blocked, EncryptedBlocked]{ +var blockedPlan = gensupport.New(gensupport.Generated[Blocked, EncryptedBlocked]{ Plan: blockedSpec, - Source: func(v Blocked) stashrt.Values { - return stashrt.Values{"email": v.Email} + Source: func(v Blocked) gensupport.Values { + return gensupport.Values{"email": v.Email} }, - Seal: func(v Blocked, rec stashrt.Record) EncryptedBlocked { + Seal: func(v Blocked, rec gensupport.Record) EncryptedBlocked { email := rec["email"] return EncryptedBlocked{Email: EncryptedBlockedEmail{Ciphertext: email.Ciphertext, Equality: email.Equality}} }, - Open: func(e EncryptedBlocked) stashrt.Record { - return stashrt.Record{ + Open: func(e EncryptedBlocked) gensupport.Record { + return gensupport.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality}, } }, - Value: func(e EncryptedBlocked, vals stashrt.Values) (Blocked, error) { - email, err := stashrt.Get[string](vals, "email") + Value: func(e EncryptedBlocked, vals gensupport.Values) (Blocked, error) { + email, err := gensupport.Get[string](vals, "email") if err != nil { return Blocked{}, err } @@ -61,11 +64,11 @@ func (EncryptedBlocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBloc var BlockedFields = struct { Email BlockedEmailField }{ - Email: BlockedEmailField{stashrt.NewField[string](blockedSpec, "email")}, + Email: BlockedEmailField{gensupport.NewField[string](blockedSpec, "email")}, } type BlockedEmailField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f BlockedEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedBlockedEmail, error) { diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index b2f1cdf48..ebebd9c82 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -7,9 +7,12 @@ import ( "example.com/app/crm" "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" ) +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + type EncryptedContact struct { ID int64 Email EncryptedContactEmail @@ -39,16 +42,16 @@ type contactShape struct { // crm.Contact is not this package's type, so it cannot have a StashPlan // method. Call the plan's methods. -var contactSpec = stashrt.NewPlan("contacts"). +var contactSpec = gensupport.NewPlan("contacts"). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). EncryptIndex("phone_number", stackencrypt.Equality) -var ContactPlan = stashrt.New(stashrt.Generated[crm.Contact, EncryptedContact]{ +var ContactPlan = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ Plan: contactSpec, - Source: func(v crm.Contact) stashrt.Values { - return stashrt.Values{"email": v.Email, "phone_number": v.PhoneNumber} + Source: func(v crm.Contact) gensupport.Values { + return gensupport.Values{"email": v.Email, "phone_number": v.PhoneNumber} }, - Seal: func(v crm.Contact, rec stashrt.Record) EncryptedContact { + Seal: func(v crm.Contact, rec gensupport.Record) EncryptedContact { email, phone := rec["email"], rec["phone_number"] return EncryptedContact{ ID: v.ID, @@ -56,19 +59,19 @@ var ContactPlan = stashrt.New(stashrt.Generated[crm.Contact, EncryptedContact]{ PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, } }, - Open: func(e EncryptedContact) stashrt.Record { - return stashrt.Record{ + Open: func(e EncryptedContact) gensupport.Record { + return gensupport.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, } }, - Value: func(e EncryptedContact, vals stashrt.Values) (crm.Contact, error) { + Value: func(e EncryptedContact, vals gensupport.Values) (crm.Contact, error) { v := crm.Contact{ID: e.ID} var err error - if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return crm.Contact{}, err } - if v.PhoneNumber, err = stashrt.Get[string](vals, "phone_number"); err != nil { + if v.PhoneNumber, err = gensupport.Get[string](vals, "phone_number"); err != nil { return crm.Contact{}, err } return v, nil @@ -79,12 +82,12 @@ var ContactFields = struct { Email ContactEmailField PhoneNumber ContactPhoneNumberField }{ - Email: ContactEmailField{stashrt.NewField[string](contactSpec, "email")}, - PhoneNumber: ContactPhoneNumberField{stashrt.NewField[string](contactSpec, "phone_number")}, + Email: ContactEmailField{gensupport.NewField[string](contactSpec, "email")}, + PhoneNumber: ContactPhoneNumberField{gensupport.NewField[string](contactSpec, "phone_number")}, } type ContactEmailField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f ContactEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactEmail, error) { @@ -104,7 +107,7 @@ func (f ContactEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v } type ContactPhoneNumberField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f ContactPhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactPhoneNumber, error) { diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index dbde943e9..ff965c704 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -8,9 +8,12 @@ import ( "context" "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" ) +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + type EncryptedIndividual struct { ID int64 Name stackencrypt.Ciphertext @@ -41,17 +44,17 @@ type individualShape struct { Nickname string } -var individualSpec = stashrt.NewPlan("individuals"). +var individualSpec = gensupport.NewPlan("individuals"). Encrypt("name"). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). EncryptIndex("medicare_number", stackencrypt.Equality) -var individualPlan = stashrt.New(stashrt.Generated[Individual, EncryptedIndividual]{ +var individualPlan = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual]{ Plan: individualSpec, - Source: func(v Individual) stashrt.Values { - return stashrt.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} + Source: func(v Individual) gensupport.Values { + return gensupport.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} }, - Seal: func(v Individual, rec stashrt.Record) EncryptedIndividual { + Seal: func(v Individual, rec gensupport.Record) EncryptedIndividual { email, medicare := rec["email"], rec["medicare_number"] return EncryptedIndividual{ ID: v.ID, @@ -61,23 +64,23 @@ var individualPlan = stashrt.New(stashrt.Generated[Individual, EncryptedIndividu Nickname: v.Nickname, } }, - Open: func(e EncryptedIndividual) stashrt.Record { - return stashrt.Record{ + Open: func(e EncryptedIndividual) gensupport.Record { + return gensupport.Record{ "name": {Ciphertext: e.Name}, "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "medicare_number": {Ciphertext: e.MedicareNo.Ciphertext, Equality: e.MedicareNo.Equality}, } }, - Value: func(e EncryptedIndividual, vals stashrt.Values) (Individual, error) { + Value: func(e EncryptedIndividual, vals gensupport.Values) (Individual, error) { v := Individual{ID: e.ID, Nickname: e.Nickname} var err error - if v.Name, err = stashrt.Get[string](vals, "name"); err != nil { + if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { return Individual{}, err } - if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return Individual{}, err } - if v.MedicareNo, err = stashrt.Get[string](vals, "medicare_number"); err != nil { + if v.MedicareNo, err = gensupport.Get[string](vals, "medicare_number"); err != nil { return Individual{}, err } return v, nil @@ -97,13 +100,13 @@ var IndividualFields = struct { Email IndividualEmailField MedicareNo IndividualMedicareNoField }{ - Name: IndividualNameField{stashrt.NewField[string](individualSpec, "name")}, - Email: IndividualEmailField{stashrt.NewField[string](individualSpec, "email")}, - MedicareNo: IndividualMedicareNoField{stashrt.NewField[string](individualSpec, "medicare_number")}, + Name: IndividualNameField{gensupport.NewField[string](individualSpec, "name")}, + Email: IndividualEmailField{gensupport.NewField[string](individualSpec, "email")}, + MedicareNo: IndividualMedicareNoField{gensupport.NewField[string](individualSpec, "medicare_number")}, } type IndividualNameField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f IndividualNameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { @@ -112,7 +115,7 @@ func (f IndividualNameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher } type IndividualEmailField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f IndividualEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualEmail, error) { @@ -132,7 +135,7 @@ func (f IndividualEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, } type IndividualMedicareNoField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f IndividualMedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualMedicareNo, error) { diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index c508322d3..5122734f5 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -7,9 +7,12 @@ import ( "example.com/app/internal/userdb" "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/stashrt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" ) +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + type EncryptedUser struct { ID int64 Email EncryptedUserEmail @@ -43,18 +46,18 @@ type userShape struct { Internal string } -var userSpec = stashrt.NewPlan("users"). +var userSpec = gensupport.NewPlan("users"). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). Index("attrs", stackencrypt.JSON()). Encrypt("notes") -var userPlan = stashrt.New(stashrt.Generated[User, EncryptedUser]{ +var userPlan = gensupport.New(gensupport.Generated[User, EncryptedUser]{ Plan: userSpec, - Source: func(v User) stashrt.Values { - return stashrt.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} + Source: func(v User) gensupport.Values { + return gensupport.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} }, - Seal: func(v User, rec stashrt.Record) EncryptedUser { + Seal: func(v User, rec gensupport.Record) EncryptedUser { email, age := rec["email"], rec["age"] return EncryptedUser{ ID: v.ID, @@ -64,27 +67,27 @@ var userPlan = stashrt.New(stashrt.Generated[User, EncryptedUser]{ Notes: rec["notes"].Ciphertext, } }, - Open: func(e EncryptedUser) stashrt.Record { - return stashrt.Record{ + Open: func(e EncryptedUser) gensupport.Record { + return gensupport.Record{ "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, "attrs": {JSON: e.Attrs}, "notes": {Ciphertext: e.Notes}, } }, - Value: func(e EncryptedUser, vals stashrt.Values) (User, error) { + Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { v := User{ID: e.ID} var err error - if v.Email, err = stashrt.Get[string](vals, "email"); err != nil { + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return User{}, err } - if v.Age, err = stashrt.Get[uint32](vals, "age"); err != nil { + if v.Age, err = gensupport.Get[uint32](vals, "age"); err != nil { return User{}, err } - if v.Attrs, err = stashrt.Get[map[string]any](vals, "attrs"); err != nil { + if v.Attrs, err = gensupport.Get[map[string]any](vals, "attrs"); err != nil { return User{}, err } - if v.Notes, err = stashrt.Get[string](vals, "notes"); err != nil { + if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { return User{}, err } return v, nil @@ -101,14 +104,14 @@ var UserFields = struct { Attrs UserAttrsField Notes UserNotesField }{ - Email: UserEmailField{stashrt.NewField[string](userSpec, "email")}, - Age: UserAgeField{stashrt.NewField[uint32](userSpec, "age")}, - Attrs: UserAttrsField{stashrt.NewField[map[string]any](userSpec, "attrs")}, - Notes: UserNotesField{stashrt.NewField[string](userSpec, "notes")}, + Email: UserEmailField{gensupport.NewField[string](userSpec, "email")}, + Age: UserAgeField{gensupport.NewField[uint32](userSpec, "age")}, + Attrs: UserAttrsField{gensupport.NewField[map[string]any](userSpec, "attrs")}, + Notes: UserNotesField{gensupport.NewField[string](userSpec, "notes")}, } type UserEmailField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f UserEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedUserEmail, error) { @@ -128,7 +131,7 @@ func (f UserEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v str } type UserAgeField struct { - plan stashrt.Field[uint32] + plan gensupport.Field[uint32] } func (f UserAgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (EncryptedUserAge, error) { @@ -148,7 +151,7 @@ func (f UserAgeField) Ore(ctx context.Context, c *stackencrypt.Cipher, v uint32, } type UserAttrsField struct { - plan stashrt.Field[map[string]any] + plan gensupport.Field[map[string]any] } func (f UserAttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONDocument, error) { @@ -169,7 +172,7 @@ func (f UserAttrsField) EqualAt(ctx context.Context, c *stackencrypt.Cipher, pat } type UserNotesField struct { - plan stashrt.Field[string] + plan gensupport.Field[string] } func (f UserNotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { @@ -216,12 +219,12 @@ func (s userRowShape) encrypted() EncryptedUser { } } -var UserRows = stashrt.Rows(userPlan, +var UserRows = gensupport.Rows(userPlan, func(e EncryptedUser) UserRow { return UserRow(userRowShapeOf(e)) }, func(r UserRow) EncryptedUser { return userRowShape(r).encrypted() }, ) -var SQLCUsers = stashrt.Rows(userPlan, +var SQLCUsers = gensupport.Rows(userPlan, func(e EncryptedUser) userdb.User { return userdb.User(userRowShapeOf(e)) }, func(r userdb.User) EncryptedUser { return userRowShape(r).encrypted() }, ) From 3ce912984e489863be05dc2b23aa082df85608c3 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 13:49:53 +1100 Subject: [PATCH 07/22] docs(plans): one Encrypt over a slice; usage steps before the stashgen reference Two changes to the Go binding section. The plan states the design; the reasons are here. One Encrypt, one Decrypt. EncryptAll and DecryptAll are removed, and Encrypt and Decrypt take a slice and return a slice. A caller with one value passes a slice with one element. The two forms did the same work. Encrypt with one value made the same single ZeroKMS request as EncryptAll with one element, so the single form bought no speed. What it did do was make the slow path the one with the short name: a loop that calls Encrypt for each row sends one request for each row. With only the slice form, the call that reads most naturally is the batched one. It is also half the surface, on the package functions and on RowPlan. The cost is at a call site with one value, which wraps the value in a slice and reads element 0 of the result. The plan states the contract that makes this safe: the result has one element for each element of the input, in the same order. Decrypt changes in the same way, so the two calls keep one shape. The query methods of a generated field entry (Equality, Ore and the rest) stay single: a term derives locally, so there is no request to share. Usage before reference. The stashgen section opened with the flag table. It now opens with seven numbered steps a reader can follow from an empty module to a CI check, then points to the three other cases (a storage struct, a type in another package, a policy), then gives the reference. ISO 24495-1 asks for the most needed information first. Every Go file type-checks (`go vet ./...`, and the generate program under `-tags stashgen`) against the uncommitted stub of the proposed API, and gofmt reports nothing. The amended text passes slipstream's language, length and line-break checks with 0 findings (178 sentences, the longest 24 words), and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 92 +++++++++++++++---- .../blocklist/blocklist.go | 5 +- .../contacts/contacts.go | 3 +- .../individualstore/store.go | 3 +- .../2026-10-04-plan-builder/users/extend.go | 8 +- .../users/gormstore.go | 4 +- .../users/sqlcstore.go | 16 ++-- .../2026-10-04-plan-builder/users/sqlstore.go | 12 +-- 8 files changed, 103 insertions(+), 40 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 31fe0c1ee..27a4b2207 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -521,13 +521,12 @@ type User struct { cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) -enc, err := stackencrypt.Encrypt(ctx, cipher, alice) // EncryptedUser -all, err := stackencrypt.EncryptAll(ctx, cipher, people) // []EncryptedUser, one ZeroKMS request -user, err := stackencrypt.Decrypt(ctx, client, enc) // User +encrypted, err := stackencrypt.Encrypt(ctx, cipher, people) // []EncryptedUser, one ZeroKMS request +users, err := stackencrypt.Decrypt(ctx, client, encrypted) // []User term, err := UserFields.Email.Equality(ctx, cipher, "bob@example.com") // EqualityTerm -_ = enc.Email.Equality // EqualityTerm -_ = enc.Email.Ore // does not compile: email declares no ORE index +_ = encrypted[0].Email.Equality // EqualityTerm +_ = encrypted[0].Email.Ore // does not compile: email declares no ORE index ``` ### Struct tags @@ -551,14 +550,63 @@ Only `stashgen` reads these tags, and no function reads them at run time. ### stashgen -`stashgen` is a Go command at `languages/golang/cmd/stashgen`. -A module adds it with `go get -tool`, and `go generate` runs it from a comment beside the type: +`stashgen` is a Go command that reads the `stash` tags of a struct and writes its encrypted type. +It is at `languages/golang/cmd/stashgen`. -```go -//go:generate go tool stashgen -type User -row UserRows=UserRow -row SQLCUsers=userdb.User -``` +#### Use stashgen + +1. Add the tool to your module. + This needs Go 1.24 or later. + + ```sh + go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen + ``` + +2. Put `stash` tags on the struct, and a `go:generate` comment beside it. + + ```go + //go:generate go tool stashgen -type User + type User struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt,index=equality;match"` + } + ``` + +3. Run the generator. + It writes `user_stash.go` beside the struct. + + ```sh + go generate ./... + ``` + +4. Commit the generated file. + +5. Call the generated code. -It takes these flags: + ```go + encrypted, err := stackencrypt.Encrypt(ctx, cipher, []User{alice}) + term, err := UserFields.Email.Equality(ctx, cipher, "bob@example.com") + ``` + +6. Run the generator again after each change to the struct or to a tag. + A change to the fields of the struct stops the build until you do. + +7. In CI, run the generator and fail when a generated file changes. + + ```sh + go generate ./... && git diff --exit-code + ``` + +Three more cases use the same steps with one more flag or one more file: + +- To encrypt into a GORM model, an sqlc model or another storage struct, add `-row`. See "Storage structs". +- To encrypt a type from another package, add `-for`. See "Types in another package". +- To decide the plan from a policy, write a generate program. See "Plans from a policy". + +The rest of this section is the reference. + +#### Flags | Flag | Meaning | |---|---| @@ -568,7 +616,9 @@ It takes these flags: | `-for P.F` | `T` declares the plan for `F`, a type in another package. | | `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | -`stashgen` loads the package with `golang.org/x/tools/go/packages` and reads types, not text. +#### How stashgen reads a package + +The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text. It runs none of the package's code. It ignores its own output file when it loads the package, so a stale file does not stop it. The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. @@ -615,7 +665,7 @@ Each of its fields carries a `stash` tag that names one output: For sqlc, a `go_struct_tag` override puts the tag on the generated model. For `-row UserRows=UserRow`, `stashgen` writes `UserRows`, a `RowPlan[User, UserRow]`. -Its `Encrypt` returns a `UserRow`, and its `Decrypt` takes one. +Its `Encrypt` returns a `[]UserRow`, and its `Decrypt` takes one. The generator refuses a storage struct that has a field with no tag. It also refuses one that has no field for an output of the plan. @@ -750,11 +800,9 @@ So a file from another version does not compile. | Call | Returns | |---|---| -| `Encrypt(ctx, c *Cipher, v T, opts ...Option)`, for a `Planned` type | the generated type `E` | -| `EncryptAll(ctx, c *Cipher, vs []T, opts ...Option)` | `[]E` | -| `Decrypt(ctx, d Decrypter, e E, opts ...Option)` | `T` | -| `DecryptAll(ctx, d Decrypter, es []E, opts ...Option)` | `[]T` | -| `RowPlan[T, R]`: the same four, as methods | `R` in place of `E` | +| `Encrypt(ctx, c *Cipher, vs []T, opts ...Option)`, for a `Planned` type | `[]E`, where `E` is the generated type | +| `Decrypt(ctx, d Decrypter, es []E, opts ...Option)` | `[]T` | +| `RowPlan[T, R]`: the same two, as methods | `[]R` in place of `[]E` | | A generated field entry, such as `UserFields.Email`: `Encrypt` | the field's generated type | | A generated field entry: `Equality`, `Match`, `Ore`, `Ope` | `EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` | | A generated field entry: `Contains`, `EqualAt` | `JSONQuery` | @@ -763,7 +811,11 @@ So a file from another version does not compile. | `DecryptValue[T](ctx, d Decrypter, ct Ciphertext, c Context, opts ...Option)` | `T` | Every call also returns an `error`. -`EncryptAll` and `DecryptAll` send one ZeroKMS request for the whole slice. +`Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. +The result has one element for each element of the input, in the same order. +For one value, pass a slice with one element. +There is no second form for one value. + `NewContext(bytes)` makes a `Context` from raw bytes, the same bytes part that Rust accepts. See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go) and [`documents/documents.go`](2026-10-04-plan-builder/documents/documents.go). @@ -1020,7 +1072,7 @@ Then: ## Open questions - **A batch across plans in Go.** - `EncryptAll` and `DecryptAll` batch the values of one plan. + `Encrypt` and `Decrypt` batch the values of one plan. The Go form of `all(..)` gives a typed handle for each operation, and the Go PR settles its spelling. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go index f285ec59d..35a359451 100644 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go +++ b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go @@ -26,10 +26,11 @@ func New(db *sql.DB, cipher *stackencrypt.Cipher) *List { } func (l *List) Block(ctx context.Context, email string) error { - enc, err := stackencrypt.Encrypt(ctx, l.cipher, Blocked{Email: email}) + encrypted, err := stackencrypt.Encrypt(ctx, l.cipher, []Blocked{{Email: email}}) if err != nil { return err } + enc := encrypted[0] _, err = l.db.ExecContext(ctx, `INSERT INTO blocked_emails (email, email_eq) VALUES ($1, $2) ON CONFLICT (email_eq) DO NOTHING`, enc.Email.Ciphertext, enc.Email.Equality) @@ -67,7 +68,7 @@ func (l *List) All(ctx context.Context) ([]string, error) { if err := rs.Err(); err != nil { return nil, err } - blocked, err := stackencrypt.DecryptAll(ctx, l.cipher, encrypted) + blocked, err := stackencrypt.Decrypt(ctx, l.cipher, encrypted) if err != nil { return nil, err } diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go index 1c36f2174..7a521c27c 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -23,10 +23,11 @@ type contactStash struct { } func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, contact crm.Contact) error { - enc, err := ContactPlan.Encrypt(ctx, cipher, contact) + encrypted, err := ContactPlan.Encrypt(ctx, cipher, []crm.Contact{contact}) if err != nil { return err } + enc := encrypted[0] _, err = db.ExecContext(ctx, ` INSERT INTO contacts (id, email, email_eq, email_match, phone_number, phone_number_eq) VALUES ($1, $2, $3, $4, $5, $6)`, diff --git a/docs/plans/2026-10-04-plan-builder/individualstore/store.go b/docs/plans/2026-10-04-plan-builder/individualstore/store.go index 0729a430d..5e0f950f2 100644 --- a/docs/plans/2026-10-04-plan-builder/individualstore/store.go +++ b/docs/plans/2026-10-04-plan-builder/individualstore/store.go @@ -10,10 +10,11 @@ import ( ) func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person individuals.Individual) error { - enc, err := stackencrypt.Encrypt(ctx, cipher, person) + encrypted, err := stackencrypt.Encrypt(ctx, cipher, []individuals.Individual{person}) if err != nil { return err } + enc := encrypted[0] _, err = db.ExecContext(ctx, ` INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, diff --git a/docs/plans/2026-10-04-plan-builder/users/extend.go b/docs/plans/2026-10-04-plan-builder/users/extend.go index 11a6ab116..8fb6f1b7c 100644 --- a/docs/plans/2026-10-04-plan-builder/users/extend.go +++ b/docs/plans/2026-10-04-plan-builder/users/extend.go @@ -12,12 +12,16 @@ import ( func RoundTripForTenant(ctx context.Context, cipher *stackencrypt.Cipher, part string, user User) (User, error) { extend := stackencrypt.ExtendContext(part) - enc, err := stackencrypt.Encrypt(ctx, cipher, user, extend) + encrypted, err := stackencrypt.Encrypt(ctx, cipher, []User{user}, extend) if err != nil { return User{}, err } if _, err := UserFields.Email.Equality(ctx, cipher, user.Email, extend); err != nil { return User{}, err } - return stackencrypt.Decrypt(ctx, cipher, enc, extend) + users, err := stackencrypt.Decrypt(ctx, cipher, encrypted, extend) + if err != nil { + return User{}, err + } + return users[0], nil } diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index 2e4a3ca0b..d6f44e200 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -39,7 +39,7 @@ func NewGormStore(db *gorm.DB, client *stackencrypt.Client) *GormStore { func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) error { cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - rows, err := UserRows.EncryptAll(ctx, cipher, people) + rows, err := UserRows.Encrypt(ctx, cipher, people) if err != nil { return err } @@ -56,5 +56,5 @@ func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]Us if err := s.db.WithContext(ctx).Where("email_eq = ?", term).Find(&rows).Error; err != nil { return nil, err } - return UserRows.DecryptAll(ctx, cipher, rows) + return UserRows.Decrypt(ctx, cipher, rows) } diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index b1ccf9d17..2b8cc03e5 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -28,20 +28,20 @@ func (s *SQLCStore) cipher(tenant string) *stackencrypt.Cipher { } func (s *SQLCStore) Create(ctx context.Context, tenant string, user User) error { - row, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), user) + rows, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), []User{user}) if err != nil { return fmt.Errorf("encrypt user %d: %w", user.ID, err) } // CreateUserParams has the same fields as userdb.User, in the same order, // so Go converts one to the other. If a change to the query breaks that, // this line stops compiling. - return s.queries.CreateUser(ctx, userdb.CreateUserParams(row)) + return s.queries.CreateUser(ctx, userdb.CreateUserParams(rows[0])) } // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. func (s *SQLCStore) Import(ctx context.Context, tenant string, people []User) error { - rows, err := SQLCUsers.EncryptAll(ctx, s.cipher(tenant), people) + rows, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) } @@ -69,7 +69,11 @@ func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, err if err != nil { return User{}, err } - return SQLCUsers.Decrypt(ctx, s.cipher(tenant), row) + users, err := SQLCUsers.Decrypt(ctx, s.cipher(tenant), []userdb.User{row}) + if err != nil { + return User{}, err + } + return users[0], nil } func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { @@ -82,7 +86,7 @@ func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]Us if err != nil { return nil, err } - return SQLCUsers.DecryptAll(ctx, cipher, rows) + return SQLCUsers.Decrypt(ctx, cipher, rows) } func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { @@ -90,7 +94,7 @@ func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { if err != nil { return nil, err } - return SQLCUsers.DecryptAll(ctx, s.cipher(tenant), rows) + return SQLCUsers.Decrypt(ctx, s.cipher(tenant), rows) } // ChangeEmail rewrites one field. The email field owns three columns, and all diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go index 7bca19b49..cbceadf62 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -31,18 +31,18 @@ func (s *SQLStore) cipher(tenant string) *stackencrypt.Cipher { } func (s *SQLStore) Create(ctx context.Context, tenant string, user User) error { - enc, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), user) + encrypted, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), []User{user}) if err != nil { return fmt.Errorf("encrypt user %d: %w", user.ID, err) } - _, err = s.db.ExecContext(ctx, insertUser, args(enc)...) + _, err = s.db.ExecContext(ctx, insertUser, args(encrypted[0])...) return err } // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) error { - encrypted, err := stackencrypt.EncryptAll(ctx, s.cipher(tenant), people) + encrypted, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) } @@ -77,7 +77,7 @@ func (s *SQLStore) FindByEmail(ctx context.Context, tenant, email string) ([]Use if err != nil { return nil, err } - return stackencrypt.DecryptAll(ctx, cipher, encrypted) + return stackencrypt.Decrypt(ctx, cipher, encrypted) } // OldestFirst returns users aged minAge or over, oldest first. ORE terms @@ -94,7 +94,7 @@ func (s *SQLStore) OldestFirst(ctx context.Context, tenant string, minAge uint32 } encrypted = slices.DeleteFunc(encrypted, func(e EncryptedUser) bool { return e.Age.Ore.Compare(floor) < 0 }) slices.SortFunc(encrypted, func(a, b EncryptedUser) int { return b.Age.Ore.Compare(a.Age.Ore) }) - return stackencrypt.DecryptAll(ctx, cipher, encrypted) + return stackencrypt.Decrypt(ctx, cipher, encrypted) } func (s *SQLStore) WithRole(ctx context.Context, tenant, role string) ([]User, error) { @@ -108,7 +108,7 @@ func (s *SQLStore) WithRole(ctx context.Context, tenant, role string) ([]User, e if err != nil { return nil, err } - return stackencrypt.DecryptAll(ctx, cipher, encrypted) + return stackencrypt.Decrypt(ctx, cipher, encrypted) } func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]EncryptedUser, error) { From e4f9697ccc3cdd65be9177e0aa2b69b83f41ce9b Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 14:28:04 +1100 Subject: [PATCH 08/22] docs(plans): say what GoField is in the policy example A fact carries two names for one field, Field and GoField, and the example gave no reason for the second. Field is the schema's name, which rules match on and which names the column. GoField is the Go struct field that holds the value, which the generator reads from and reuses as the name of the encrypted type's field. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder/policy/policy.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/plans/2026-10-04-plan-builder/policy/policy.go b/docs/plans/2026-10-04-plan-builder/policy/policy.go index d591b0d60..85a66c84e 100644 --- a/docs/plans/2026-10-04-plan-builder/policy/policy.go +++ b/docs/plans/2026-10-04-plan-builder/policy/policy.go @@ -16,6 +16,12 @@ func categories(values ...string) []plan.Annotation { // Source states the facts a schema reader would produce. A protobuf source // reads the same facts from descriptors and their custom options. +// +// A fact has two names because the schema and the Go struct spell a field +// differently. Field is the schema's name: rules match on it, and it names +// the column unless plan.Column sets another. GoField is the field of +// individuals.Individual that holds the value, and the generator gives the +// field of EncryptedIndividual the same name. With no GoField, Field is both. var Source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { return []plan.Fact{ {Field: "id", GoField: "ID"}, From a30b9d2c8bbdda9ea08dd28ad2c5fe84ad4a3651 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 17:14:36 +1100 Subject: [PATCH 09/22] docs: language SDK design principles, with ADR-0002 Adds docs/sdk-design-principles.md: eight principles for every language SDK and thirteen for the Go SDK. ADR-0002 records the decision to adopt them, and AGENTS.md points agents at the document before they design or change a language SDK. Why write them down. The Go design in #1070 was settled one objection at a time: the result type, a forgotten call, a field looked up by string, a check at startup. Each fix reopened the same questions. When is a mistake found? What does the user see? What must match across languages? Without an answer on the page, the next language SDK would argue all of it again. How they were set. Each principle was reviewed and accepted one at a time, and several were changed in review: - "Find mistakes at compile time" gained a fixed order of stages, so the three cases Go cannot catch in the compiler do not read as violations. - "Mirror the Rust concepts" became "take the host language's shape". The plan is hidden from a Go user altogether, which goes further than reshaping its calls. - "One way to do each thing" replaced a package function, an interface and a marker method with generated functions in the user's package. - Dave Cheney's API advice is an input and not a named source. Two rulings already depart from it: a slice parameter in place of a variadic one, and package-level generated values. Why two terms. "Binding" was being used for the user-facing library and for the interface to the engine. They are different things with different rules, so the document fixes one word for each. Why an order for conflicts. Go idiom and compile-time guarantees pulled against each other more than once. The order says which wins: the engine and the bytes, then early checks, then hiding what a user cannot act on, then idiom. Five questions are listed as not yet decided. The largest is the approach of the Go policy package. The text follows ISO 24495-1 and ASD-STE100 and passes slipstream's language, length and line-break checks with 0 findings. The script tests that read AGENTS.md were not run here: this worktree has no node_modules. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- AGENTS.md | 17 ++ .../0002-language-sdk-design-principles.md | 49 ++++ .../blocklist/blocked_stash.go | 84 ------- .../blocklist/blocklist.go | 80 ------ .../contacts/contactstash_stash.go | 123 ---------- .../eql-sqlc/query.sql | 13 - .../eql-sqlc/schema.sql | 6 - .../eql-sqlc/sqlc.yaml | 23 -- .../individuals/individual_stash.go | 151 ------------ .../internal/eqldb/db.go | 31 --- .../internal/eqldb/models.go | 16 -- .../internal/eqldb/query.sql.go | 131 ---------- .../internal/userdb/db.go | 31 --- .../internal/userdb/models.go | 21 -- .../internal/userdb/query.sql.go | 160 ------------ .../{eql-sqlc => sqlc}/eql-domains.sql | 0 .../2026-10-04-plan-builder/sqlc/query.sql | 18 +- .../2026-10-04-plan-builder/sqlc/schema.sql | 15 +- .../2026-10-04-plan-builder/sqlc/sqlc.yaml | 44 ++-- .../2026-10-04-plan-builder/users/extend.go | 27 -- .../users/user_stash.go | 230 ------------------ docs/sdk-design-principles.md | 217 +++++++++++++++++ 22 files changed, 310 insertions(+), 1177 deletions(-) create mode 100644 docs/adr/0002-language-sdk-design-principles.md delete mode 100644 docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go delete mode 100644 docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go delete mode 100644 docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go delete mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql delete mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql delete mode 100644 docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml delete mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/db.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/models.go delete mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go rename docs/plans/2026-10-04-plan-builder/{eql-sqlc => sqlc}/eql-domains.sql (100%) delete mode 100644 docs/plans/2026-10-04-plan-builder/users/extend.go delete mode 100644 docs/plans/2026-10-04-plan-builder/users/user_stash.go create mode 100644 docs/sdk-design-principles.md diff --git a/AGENTS.md b/AGENTS.md index d159d229b..c2af35d07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -762,6 +762,22 @@ replacement such as `Some(Default::default())` for `Some(())`. Document the reason and match the specific replacement so a reachable, behavior-changing mutation in the same function stays covered. +## Designing a language SDK + +Read [`docs/sdk-design-principles.md`](docs/sdk-design-principles.md) before you +architect, design or build a language SDK, or change the Go module's public +surface. It holds eight principles for every language SDK and thirteen for the +Go SDK, and the order to apply them in when two disagree. +`docs/adr/0002-language-sdk-design-principles.md` records why. + +Two terms from it are used across this repository: + +- A **binding** is the FFI or WASI interface between the Rust engine and a + target language. +- A **language SDK** is what users of the target language work with day to day. + +Do not call the user-facing library a binding. + ## Agent Skills — these ship to customers `skills/*/SKILL.md` are **published artifacts, not internal notes.** Treat a wrong @@ -961,6 +977,7 @@ pnpm changeset:publish - `languages/typescript/packages/cli/AGENTS.md` for CLI-specific guidance - `e2e/README.md` for the cross-package E2E suite - `skills/*/SKILL.md` for per-integration agent guides +- `docs/sdk-design-principles.md` for the language SDK design principles - User-facing docs (concepts, reference, how-to) live on the docs site: - https://cipherstash.com/docs - https://cipherstash.com/docs/stack/quickstart diff --git a/docs/adr/0002-language-sdk-design-principles.md b/docs/adr/0002-language-sdk-design-principles.md new file mode 100644 index 000000000..60fd0ce25 --- /dev/null +++ b/docs/adr/0002-language-sdk-design-principles.md @@ -0,0 +1,49 @@ +--- +status: accepted +date: 2026-10-05 +--- + +# Language SDKs follow one set of design principles + +Every language SDK for Stack Encrypt follows [the language SDK design principles](../sdk-design-principles.md). +That document holds the principles. +This ADR records the decision to adopt them, and why. + +## The problem + +The first Go design mirrored the Rust chain: one `Encrypt` verb, `Using(plan)`, and a `Run(ctx)` call in place of `.await`. +A review found that the shape did not fit Go. +One builder with one `Run` could only return `any`. +A forgotten `Run` compiled and did nothing. +A typing mistake in a field name wrote NULL. + +Each of those was fixed in turn, and each fix raised the same questions again. +When does a mistake get found? +What does the user see? +What must be the same in every language, and what can differ? +There was no written answer, so each language SDK would have argued them from the start. + +## Decision + +We adopt the principles in [`docs/sdk-design-principles.md`](../sdk-design-principles.md). +Eight apply to every language SDK, and thirteen apply to the Go SDK. + +The document also fixes two terms. +A binding is the FFI or WASI interface between the engine and a target language. +A language SDK is what users of the target language work with day to day. + +When two principles disagree, the document gives the order to apply them in. + +## Consequences + +- A design for a language SDK is reviewed against the principles, and a departure needs a stated reason. +- The Go SDK generates code, because Go cannot express a type that matches the input in any other way. + Its users write struct tags and call generated functions, and they never see a plan. +- An SDK in a typed language finds most mistakes before the program runs. + An SDK in a dynamic language ships rules for the language's type checkers and linters. +- Each language SDK is its own design. + The principles say what must match between them: the bytes, the words for engine behaviour, and fail-closed behaviour. +- Five questions are open, and the principles document lists them. + The largest is the approach of the Go policy package. + +ADR-0007 in `packages/stack-encrypt/docs/adr/` is the ground for the first principle: an SDK enters the engine through a declaration, and never through a second executor. diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go deleted file mode 100644 index 5a63f308d..000000000 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocked_stash.go +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated by stashgen. DO NOT EDIT. - -package blocklist - -import ( - "context" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" -) - -// Stops compiling when the library does not accept this version of generated file. -const _ = gensupport.GeneratedVersion1 - -type EncryptedBlocked struct { - Email EncryptedBlockedEmail -} - -type EncryptedBlockedEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm -} - -// Stops compiling when Blocked gains, loses, reorders or retypes a field. -var _ = blockedShape(Blocked{}) - -type blockedShape struct { - _ struct{} - Email string -} - -var blockedSpec = gensupport.NewPlan("blocked_emails"). - EncryptIndex("email", stackencrypt.Equality) - -var blockedPlan = gensupport.New(gensupport.Generated[Blocked, EncryptedBlocked]{ - Plan: blockedSpec, - Source: func(v Blocked) gensupport.Values { - return gensupport.Values{"email": v.Email} - }, - Seal: func(v Blocked, rec gensupport.Record) EncryptedBlocked { - email := rec["email"] - return EncryptedBlocked{Email: EncryptedBlockedEmail{Ciphertext: email.Ciphertext, Equality: email.Equality}} - }, - Open: func(e EncryptedBlocked) gensupport.Record { - return gensupport.Record{ - "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality}, - } - }, - Value: func(e EncryptedBlocked, vals gensupport.Values) (Blocked, error) { - email, err := gensupport.Get[string](vals, "email") - if err != nil { - return Blocked{}, err - } - return Blocked{Email: email}, nil - }, -}) - -func (Blocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBlocked] { return blockedPlan } - -func (EncryptedBlocked) StashPlan() *stackencrypt.RowPlan[Blocked, EncryptedBlocked] { - return blockedPlan -} - -var BlockedFields = struct { - Email BlockedEmailField -}{ - Email: BlockedEmailField{gensupport.NewField[string](blockedSpec, "email")}, -} - -type BlockedEmailField struct { - plan gensupport.Field[string] -} - -func (f BlockedEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedBlockedEmail, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedBlockedEmail{}, err - } - return EncryptedBlockedEmail{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil -} - -func (f BlockedEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} diff --git a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go b/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go deleted file mode 100644 index 35a359451..000000000 --- a/docs/plans/2026-10-04-plan-builder/blocklist/blocklist.go +++ /dev/null @@ -1,80 +0,0 @@ -// Package blocklist keeps blocked email addresses. A value with no record -// around it is a struct with one field. -package blocklist - -import ( - "context" - "database/sql" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" -) - -//go:generate go tool stashgen -type Blocked - -type Blocked struct { - _ struct{} `stash:"context=blocked_emails"` - Email string `stash:"email,encrypt,index=equality"` -} - -type List struct { - db *sql.DB - cipher *stackencrypt.Cipher -} - -func New(db *sql.DB, cipher *stackencrypt.Cipher) *List { - return &List{db: db, cipher: cipher} -} - -func (l *List) Block(ctx context.Context, email string) error { - encrypted, err := stackencrypt.Encrypt(ctx, l.cipher, []Blocked{{Email: email}}) - if err != nil { - return err - } - enc := encrypted[0] - _, err = l.db.ExecContext(ctx, - `INSERT INTO blocked_emails (email, email_eq) VALUES ($1, $2) ON CONFLICT (email_eq) DO NOTHING`, - enc.Email.Ciphertext, enc.Email.Equality) - return err -} - -func (l *List) Blocked(ctx context.Context, email string) (bool, error) { - term, err := BlockedFields.Email.Equality(ctx, l.cipher, email) - if err != nil { - return false, err - } - var blocked bool - err = l.db.QueryRowContext(ctx, - `SELECT EXISTS (SELECT 1 FROM blocked_emails WHERE email_eq = $1)`, term).Scan(&blocked) - return blocked, err -} - -// All returns every blocked address, for review. Decryption reads only the -// ciphertext column. -func (l *List) All(ctx context.Context) ([]string, error) { - rs, err := l.db.QueryContext(ctx, `SELECT email FROM blocked_emails`) - if err != nil { - return nil, err - } - defer rs.Close() - - var encrypted []EncryptedBlocked - for rs.Next() { - var e EncryptedBlocked - if err := rs.Scan(&e.Email.Ciphertext); err != nil { - return nil, err - } - encrypted = append(encrypted, e) - } - if err := rs.Err(); err != nil { - return nil, err - } - blocked, err := stackencrypt.Decrypt(ctx, l.cipher, encrypted) - if err != nil { - return nil, err - } - emails := make([]string, len(blocked)) - for i, b := range blocked { - emails[i] = b.Email - } - return emails, nil -} diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go deleted file mode 100644 index ebebd9c82..000000000 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ /dev/null @@ -1,123 +0,0 @@ -// Code generated by stashgen. DO NOT EDIT. - -package contacts - -import ( - "context" - - "example.com/app/crm" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" -) - -// Stops compiling when the library does not accept this version of generated file. -const _ = gensupport.GeneratedVersion1 - -type EncryptedContact struct { - ID int64 - Email EncryptedContactEmail - PhoneNumber EncryptedContactPhoneNumber -} - -type EncryptedContactEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Match stackencrypt.MatchTerm -} - -type EncryptedContactPhoneNumber struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm -} - -// Stops compiling when crm.Contact gains, loses, reorders or retypes a field. -var _ = contactShape(crm.Contact{}) - -type contactShape struct { - ID int64 - Email string - PhoneNumber string - Internal string -} - -// crm.Contact is not this package's type, so it cannot have a StashPlan -// method. Call the plan's methods. -var contactSpec = gensupport.NewPlan("contacts"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("phone_number", stackencrypt.Equality) - -var ContactPlan = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ - Plan: contactSpec, - Source: func(v crm.Contact) gensupport.Values { - return gensupport.Values{"email": v.Email, "phone_number": v.PhoneNumber} - }, - Seal: func(v crm.Contact, rec gensupport.Record) EncryptedContact { - email, phone := rec["email"], rec["phone_number"] - return EncryptedContact{ - ID: v.ID, - Email: EncryptedContactEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, - } - }, - Open: func(e EncryptedContact) gensupport.Record { - return gensupport.Record{ - "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, - "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, - } - }, - Value: func(e EncryptedContact, vals gensupport.Values) (crm.Contact, error) { - v := crm.Contact{ID: e.ID} - var err error - if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { - return crm.Contact{}, err - } - if v.PhoneNumber, err = gensupport.Get[string](vals, "phone_number"); err != nil { - return crm.Contact{}, err - } - return v, nil - }, -}) - -var ContactFields = struct { - Email ContactEmailField - PhoneNumber ContactPhoneNumberField -}{ - Email: ContactEmailField{gensupport.NewField[string](contactSpec, "email")}, - PhoneNumber: ContactPhoneNumberField{gensupport.NewField[string](contactSpec, "phone_number")}, -} - -type ContactEmailField struct { - plan gensupport.Field[string] -} - -func (f ContactEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactEmail, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedContactEmail{}, err - } - return EncryptedContactEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil -} - -func (f ContactEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} - -func (f ContactEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { - return f.plan.Match(ctx, c, v, opts...) -} - -type ContactPhoneNumberField struct { - plan gensupport.Field[string] -} - -func (f ContactPhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedContactPhoneNumber, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedContactPhoneNumber{}, err - } - return EncryptedContactPhoneNumber{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil -} - -func (f ContactPhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql deleted file mode 100644 index ccc33ca35..000000000 --- a/docs/plans/2026-10-04-plan-builder/eql-sqlc/query.sql +++ /dev/null @@ -1,13 +0,0 @@ --- name: CreateUser :exec -INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4); - --- One cast, straight to the query domain: sqlc types a parameter by its first --- cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. --- name: FindUsersByEmail :many -SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_search; - --- name: SearchUsersByEmail :many -SELECT * FROM users WHERE email @@ sqlc.arg(pattern)::eql_v3.query_text_search; - --- name: ListUsersOlderThan :many -SELECT * FROM users WHERE age > sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql deleted file mode 100644 index 0a78502cd..000000000 --- a/docs/plans/2026-10-04-plan-builder/eql-sqlc/schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE users ( - id bigint PRIMARY KEY, - email public.eql_v3_text_search NOT NULL, - age public.eql_v3_integer_ord NOT NULL, - attrs public.eql_v3_json_search NOT NULL -); diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml deleted file mode 100644 index 5b9cbaf44..000000000 --- a/docs/plans/2026-10-04-plan-builder/eql-sqlc/sqlc.yaml +++ /dev/null @@ -1,23 +0,0 @@ -version: "2" -sql: - - engine: postgresql - schema: - - eql-domains.sql - - schema.sql - queries: query.sql - gen: - go: - package: eqldb - out: ../internal/eqldb - emit_db_tags: true - overrides: - - db_type: "public.eql_v3_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: TextSearch } - - db_type: "public.eql_v3_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: IntegerOrd } - - db_type: "public.eql_v3_json_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: JSONSearch } - - db_type: "eql_v3.query_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryTextSearch } - - db_type: "eql_v3.query_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryIntegerOrd } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go deleted file mode 100644 index ff965c704..000000000 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ /dev/null @@ -1,151 +0,0 @@ -// Code generated by stashgen. DO NOT EDIT. - -//go:build !stashgen - -package individuals - -import ( - "context" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" -) - -// Stops compiling when the library does not accept this version of generated file. -const _ = gensupport.GeneratedVersion1 - -type EncryptedIndividual struct { - ID int64 - Name stackencrypt.Ciphertext - Email EncryptedIndividualEmail - MedicareNo EncryptedIndividualMedicareNo - Nickname string -} - -type EncryptedIndividualEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Match stackencrypt.MatchTerm -} - -type EncryptedIndividualMedicareNo struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm -} - -// Stops compiling when Individual gains, loses, reorders or retypes a field. -var _ = individualShape(Individual{}) - -type individualShape struct { - ID int64 - Name string - Email string - MedicareNo string - Nickname string -} - -var individualSpec = gensupport.NewPlan("individuals"). - Encrypt("name"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("medicare_number", stackencrypt.Equality) - -var individualPlan = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual]{ - Plan: individualSpec, - Source: func(v Individual) gensupport.Values { - return gensupport.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} - }, - Seal: func(v Individual, rec gensupport.Record) EncryptedIndividual { - email, medicare := rec["email"], rec["medicare_number"] - return EncryptedIndividual{ - ID: v.ID, - Name: rec["name"].Ciphertext, - Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - MedicareNo: EncryptedIndividualMedicareNo{Ciphertext: medicare.Ciphertext, Equality: medicare.Equality}, - Nickname: v.Nickname, - } - }, - Open: func(e EncryptedIndividual) gensupport.Record { - return gensupport.Record{ - "name": {Ciphertext: e.Name}, - "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, - "medicare_number": {Ciphertext: e.MedicareNo.Ciphertext, Equality: e.MedicareNo.Equality}, - } - }, - Value: func(e EncryptedIndividual, vals gensupport.Values) (Individual, error) { - v := Individual{ID: e.ID, Nickname: e.Nickname} - var err error - if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { - return Individual{}, err - } - if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { - return Individual{}, err - } - if v.MedicareNo, err = gensupport.Get[string](vals, "medicare_number"); err != nil { - return Individual{}, err - } - return v, nil - }, -}) - -func (Individual) StashPlan() *stackencrypt.RowPlan[Individual, EncryptedIndividual] { - return individualPlan -} - -func (EncryptedIndividual) StashPlan() *stackencrypt.RowPlan[Individual, EncryptedIndividual] { - return individualPlan -} - -var IndividualFields = struct { - Name IndividualNameField - Email IndividualEmailField - MedicareNo IndividualMedicareNoField -}{ - Name: IndividualNameField{gensupport.NewField[string](individualSpec, "name")}, - Email: IndividualEmailField{gensupport.NewField[string](individualSpec, "email")}, - MedicareNo: IndividualMedicareNoField{gensupport.NewField[string](individualSpec, "medicare_number")}, -} - -type IndividualNameField struct { - plan gensupport.Field[string] -} - -func (f IndividualNameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - return out.Ciphertext, err -} - -type IndividualEmailField struct { - plan gensupport.Field[string] -} - -func (f IndividualEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualEmail, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedIndividualEmail{}, err - } - return EncryptedIndividualEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil -} - -func (f IndividualEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} - -func (f IndividualEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { - return f.plan.Match(ctx, c, v, opts...) -} - -type IndividualMedicareNoField struct { - plan gensupport.Field[string] -} - -func (f IndividualMedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedIndividualMedicareNo, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedIndividualMedicareNo{}, err - } - return EncryptedIndividualMedicareNo{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil -} - -func (f IndividualMedicareNoField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go deleted file mode 100644 index 4f8ab5ecf..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/eqldb/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package eqldb - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go deleted file mode 100644 index f7ba50326..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/eqldb/models.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package eqldb - -import ( - "github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3" -) - -type User struct { - ID int64 `db:"id"` - Email eqlv3.TextSearch `db:"email"` - Age eqlv3.IntegerOrd `db:"age"` - Attrs eqlv3.JSONSearch `db:"attrs"` -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go deleted file mode 100644 index 762c18835..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/eqldb/query.sql.go +++ /dev/null @@ -1,131 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: query.sql - -package eqldb - -import ( - "context" - - "github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3" -) - -const createUser = `-- name: CreateUser :exec -INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4) -` - -type CreateUserParams struct { - ID int64 `db:"id"` - Email eqlv3.TextSearch `db:"email"` - Age eqlv3.IntegerOrd `db:"age"` - Attrs eqlv3.JSONSearch `db:"attrs"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { - _, err := q.db.ExecContext(ctx, createUser, - arg.ID, - arg.Email, - arg.Age, - arg.Attrs, - ) - return err -} - -const findUsersByEmail = `-- name: FindUsersByEmail :many -SELECT id, email, age, attrs FROM users WHERE email = $1::eql_v3.query_text_search -` - -// One cast, straight to the query domain: sqlc types a parameter by its first -// cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. -func (q *Queries) FindUsersByEmail(ctx context.Context, email eqlv3.QueryTextSearch) ([]User, error) { - rows, err := q.db.QueryContext(ctx, findUsersByEmail, email) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUsersOlderThan = `-- name: ListUsersOlderThan :many -SELECT id, email, age, attrs FROM users WHERE age > $1::eql_v3.query_integer_ord ORDER BY age -` - -func (q *Queries) ListUsersOlderThan(ctx context.Context, minAge eqlv3.QueryIntegerOrd) ([]User, error) { - rows, err := q.db.QueryContext(ctx, listUsersOlderThan, minAge) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const searchUsersByEmail = `-- name: SearchUsersByEmail :many -SELECT id, email, age, attrs FROM users WHERE email @@ $1::eql_v3.query_text_search -` - -func (q *Queries) SearchUsersByEmail(ctx context.Context, pattern eqlv3.QueryTextSearch) ([]User, error) { - rows, err := q.db.QueryContext(ctx, searchUsersByEmail, pattern) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go deleted file mode 100644 index 7e793aebb..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package userdb - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go deleted file mode 100644 index 604242b2d..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package userdb - -import ( - "github.com/cipherstash/stack/languages/golang/stackencrypt" -) - -type User struct { - ID int64 `db:"id" stash:"id"` - Email stackencrypt.Ciphertext `db:"email" stash:"email"` - EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` - Age stackencrypt.Ciphertext `db:"age" stash:"age"` - AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` - AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` - Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` - Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go deleted file mode 100644 index 9f8602131..000000000 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go +++ /dev/null @@ -1,160 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: query.sql - -package userdb - -import ( - "context" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" -) - -const createUser = `-- name: CreateUser :exec -INSERT INTO users (id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -` - -type CreateUserParams struct { - ID int64 `db:"id" stash:"id"` - Email stackencrypt.Ciphertext `db:"email" stash:"email"` - EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` - Age stackencrypt.Ciphertext `db:"age" stash:"age"` - AgeEq stackencrypt.EqualityTerm `db:"age_eq" stash:"age,equality"` - AgeOre stackencrypt.OreTerm `db:"age_ore" stash:"age,ore"` - Attrs stackencrypt.JSONDocument `db:"attrs" stash:"attrs,json"` - Notes stackencrypt.Ciphertext `db:"notes" stash:"notes"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { - _, err := q.db.ExecContext(ctx, createUser, - arg.ID, - arg.Email, - arg.EmailEq, - arg.EmailMatch, - arg.Age, - arg.AgeEq, - arg.AgeOre, - arg.Attrs, - arg.Notes, - ) - return err -} - -const findUsersByEmail = `-- name: FindUsersByEmail :many -SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users WHERE email_eq = $1 -` - -func (q *Queries) FindUsersByEmail(ctx context.Context, emailEq stackencrypt.EqualityTerm) ([]User, error) { - rows, err := q.db.QueryContext(ctx, findUsersByEmail, emailEq) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.EmailEq, - &i.EmailMatch, - &i.Age, - &i.AgeEq, - &i.AgeOre, - &i.Attrs, - &i.Notes, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUser = `-- name: GetUser :one -SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users WHERE id = $1 -` - -func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { - row := q.db.QueryRowContext(ctx, getUser, id) - var i User - err := row.Scan( - &i.ID, - &i.Email, - &i.EmailEq, - &i.EmailMatch, - &i.Age, - &i.AgeEq, - &i.AgeOre, - &i.Attrs, - &i.Notes, - ) - return i, err -} - -const listUsers = `-- name: ListUsers :many -SELECT id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes FROM users ORDER BY id -` - -func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { - rows, err := q.db.QueryContext(ctx, listUsers) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.EmailEq, - &i.EmailMatch, - &i.Age, - &i.AgeEq, - &i.AgeOre, - &i.Attrs, - &i.Notes, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateUserEmail = `-- name: UpdateUserEmail :exec -UPDATE users SET email = $2, email_eq = $3, email_match = $4 WHERE id = $1 -` - -type UpdateUserEmailParams struct { - ID int64 `db:"id" stash:"id"` - Email stackencrypt.Ciphertext `db:"email" stash:"email"` - EmailEq stackencrypt.EqualityTerm `db:"email_eq" stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `db:"email_match" stash:"email,match"` -} - -func (q *Queries) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) error { - _, err := q.db.ExecContext(ctx, updateUserEmail, - arg.ID, - arg.Email, - arg.EmailEq, - arg.EmailMatch, - ) - return err -} diff --git a/docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql b/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql similarity index 100% rename from docs/plans/2026-10-04-plan-builder/eql-sqlc/eql-domains.sql rename to docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql index 2ab429332..ccc33ca35 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql @@ -1,15 +1,13 @@ -- name: CreateUser :exec -INSERT INTO users (id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); - --- name: GetUser :one -SELECT * FROM users WHERE id = $1; +INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4); +-- One cast, straight to the query domain: sqlc types a parameter by its first +-- cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. -- name: FindUsersByEmail :many -SELECT * FROM users WHERE email_eq = $1; +SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_search; --- name: ListUsers :many -SELECT * FROM users ORDER BY id; +-- name: SearchUsersByEmail :many +SELECT * FROM users WHERE email @@ sqlc.arg(pattern)::eql_v3.query_text_search; --- name: UpdateUserEmail :exec -UPDATE users SET email = $2, email_eq = $3, email_match = $4 WHERE id = $1; +-- name: ListUsersOlderThan :many +SELECT * FROM users WHERE age > sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql index b2132a0cc..0a78502cd 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql @@ -1,13 +1,6 @@ CREATE TABLE users ( - id bigint PRIMARY KEY, - email bytea NOT NULL, - email_eq bytea NOT NULL, - email_match bytea NOT NULL, - age bytea NOT NULL, - age_eq bytea NOT NULL, - age_ore bytea NOT NULL, - attrs jsonb NOT NULL, - notes bytea NOT NULL + id bigint PRIMARY KEY, + email public.eql_v3_text_search NOT NULL, + age public.eql_v3_integer_ord NOT NULL, + attrs public.eql_v3_json_search NOT NULL ); - -CREATE INDEX users_email_eq ON users (email_eq); diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml index 9c295366f..5b9cbaf44 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml +++ b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml @@ -1,37 +1,23 @@ version: "2" sql: - engine: postgresql - schema: schema.sql + schema: + - eql-domains.sql + - schema.sql queries: query.sql gen: go: - package: userdb - out: ../internal/userdb + package: eqldb + out: ../internal/eqldb emit_db_tags: true overrides: - - column: users.id - go_struct_tag: 'stash:"id"' - - column: users.email - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } - go_struct_tag: 'stash:"email"' - - column: users.email_eq - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: EqualityTerm } - go_struct_tag: 'stash:"email,equality"' - - column: users.email_match - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: MatchTerm } - go_struct_tag: 'stash:"email,match"' - - column: users.age - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } - go_struct_tag: 'stash:"age"' - - column: users.age_eq - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: EqualityTerm } - go_struct_tag: 'stash:"age,equality"' - - column: users.age_ore - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: OreTerm } - go_struct_tag: 'stash:"age,ore"' - - column: users.attrs - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: JSONDocument } - go_struct_tag: 'stash:"attrs,json"' - - column: users.notes - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt, type: Ciphertext } - go_struct_tag: 'stash:"notes"' + - db_type: "public.eql_v3_text_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: TextSearch } + - db_type: "public.eql_v3_integer_ord" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: IntegerOrd } + - db_type: "public.eql_v3_json_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: JSONSearch } + - db_type: "eql_v3.query_text_search" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryTextSearch } + - db_type: "eql_v3.query_integer_ord" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryIntegerOrd } diff --git a/docs/plans/2026-10-04-plan-builder/users/extend.go b/docs/plans/2026-10-04-plan-builder/users/extend.go deleted file mode 100644 index 8fb6f1b7c..000000000 --- a/docs/plans/2026-10-04-plan-builder/users/extend.go +++ /dev/null @@ -1,27 +0,0 @@ -package users - -import ( - "context" - - "github.com/cipherstash/stack/languages/golang/stackencrypt" -) - -// RoundTripForTenant shows a context extension. The parts extend every field's -// context, so the write, the query and the read must pass the same parts. -// With other parts, decryption fails and the equality term matches nothing. -func RoundTripForTenant(ctx context.Context, cipher *stackencrypt.Cipher, part string, user User) (User, error) { - extend := stackencrypt.ExtendContext(part) - - encrypted, err := stackencrypt.Encrypt(ctx, cipher, []User{user}, extend) - if err != nil { - return User{}, err - } - if _, err := UserFields.Email.Equality(ctx, cipher, user.Email, extend); err != nil { - return User{}, err - } - users, err := stackencrypt.Decrypt(ctx, cipher, encrypted, extend) - if err != nil { - return User{}, err - } - return users[0], nil -} diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go deleted file mode 100644 index 5122734f5..000000000 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ /dev/null @@ -1,230 +0,0 @@ -// Code generated by stashgen. DO NOT EDIT. - -package users - -import ( - "context" - - "example.com/app/internal/userdb" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" -) - -// Stops compiling when the library does not accept this version of generated file. -const _ = gensupport.GeneratedVersion1 - -type EncryptedUser struct { - ID int64 - Email EncryptedUserEmail - Age EncryptedUserAge - Attrs stackencrypt.JSONDocument - Notes stackencrypt.Ciphertext -} - -type EncryptedUserEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Match stackencrypt.MatchTerm -} - -type EncryptedUserAge struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Ore stackencrypt.OreTerm -} - -// Stops compiling when User gains, loses, reorders or retypes a field. -var _ = userShape(User{}) - -type userShape struct { - _ struct{} - ID int64 - Email string - Age uint32 - Attrs map[string]any - Notes string - Internal string -} - -var userSpec = gensupport.NewPlan("users"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). - Index("attrs", stackencrypt.JSON()). - Encrypt("notes") - -var userPlan = gensupport.New(gensupport.Generated[User, EncryptedUser]{ - Plan: userSpec, - Source: func(v User) gensupport.Values { - return gensupport.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} - }, - Seal: func(v User, rec gensupport.Record) EncryptedUser { - email, age := rec["email"], rec["age"] - return EncryptedUser{ - ID: v.ID, - Email: EncryptedUserEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - Age: EncryptedUserAge{Ciphertext: age.Ciphertext, Equality: age.Equality, Ore: age.Ore}, - Attrs: rec["attrs"].JSON, - Notes: rec["notes"].Ciphertext, - } - }, - Open: func(e EncryptedUser) gensupport.Record { - return gensupport.Record{ - "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, - "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, - "attrs": {JSON: e.Attrs}, - "notes": {Ciphertext: e.Notes}, - } - }, - Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { - v := User{ID: e.ID} - var err error - if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { - return User{}, err - } - if v.Age, err = gensupport.Get[uint32](vals, "age"); err != nil { - return User{}, err - } - if v.Attrs, err = gensupport.Get[map[string]any](vals, "attrs"); err != nil { - return User{}, err - } - if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { - return User{}, err - } - return v, nil - }, -}) - -func (User) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } - -func (EncryptedUser) StashPlan() *stackencrypt.RowPlan[User, EncryptedUser] { return userPlan } - -var UserFields = struct { - Email UserEmailField - Age UserAgeField - Attrs UserAttrsField - Notes UserNotesField -}{ - Email: UserEmailField{gensupport.NewField[string](userSpec, "email")}, - Age: UserAgeField{gensupport.NewField[uint32](userSpec, "age")}, - Attrs: UserAttrsField{gensupport.NewField[map[string]any](userSpec, "attrs")}, - Notes: UserNotesField{gensupport.NewField[string](userSpec, "notes")}, -} - -type UserEmailField struct { - plan gensupport.Field[string] -} - -func (f UserEmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (EncryptedUserEmail, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedUserEmail{}, err - } - return EncryptedUserEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil -} - -func (f UserEmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} - -func (f UserEmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.MatchTerm, error) { - return f.plan.Match(ctx, c, v, opts...) -} - -type UserAgeField struct { - plan gensupport.Field[uint32] -} - -func (f UserAgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (EncryptedUserAge, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - if err != nil { - return EncryptedUserAge{}, err - } - return EncryptedUserAge{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil -} - -func (f UserAgeField) Equality(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (stackencrypt.EqualityTerm, error) { - return f.plan.Equality(ctx, c, v, opts...) -} - -func (f UserAgeField) Ore(ctx context.Context, c *stackencrypt.Cipher, v uint32, opts ...stackencrypt.Option) (stackencrypt.OreTerm, error) { - return f.plan.Ore(ctx, c, v, opts...) -} - -type UserAttrsField struct { - plan gensupport.Field[map[string]any] -} - -func (f UserAttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONDocument, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - return out.JSON, err -} - -func (f UserAttrsField) Contains(ctx context.Context, c *stackencrypt.Cipher, v map[string]any, opts ...stackencrypt.Option) (stackencrypt.JSONQuery, error) { - return f.plan.Contains(ctx, c, v, opts...) -} - -func (f UserAttrsField) Selector(ctx context.Context, c *stackencrypt.Cipher, path stackencrypt.JSONPath, opts ...stackencrypt.Option) (stackencrypt.JSONSelector, error) { - return f.plan.Selector(ctx, c, path, opts...) -} - -func (f UserAttrsField) EqualAt(ctx context.Context, c *stackencrypt.Cipher, path stackencrypt.JSONPath, v any, opts ...stackencrypt.Option) (stackencrypt.JSONQuery, error) { - return f.plan.EqualAt(ctx, c, path, v, opts...) -} - -type UserNotesField struct { - plan gensupport.Field[string] -} - -func (f UserNotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string, opts ...stackencrypt.Option) (stackencrypt.Ciphertext, error) { - out, err := f.plan.Encrypt(ctx, c, v, opts...) - return out.Ciphertext, err -} - -// A row struct converts to and from its shape only while the two have the same -// fields, with the same types, in the same order. A change to the row struct -// stops this file compiling. -type userRowShape struct { - ID int64 - Email stackencrypt.Ciphertext - EmailEq stackencrypt.EqualityTerm - EmailMatch stackencrypt.MatchTerm - Age stackencrypt.Ciphertext - AgeEq stackencrypt.EqualityTerm - AgeOre stackencrypt.OreTerm - Attrs stackencrypt.JSONDocument - Notes stackencrypt.Ciphertext -} - -func userRowShapeOf(e EncryptedUser) userRowShape { - return userRowShape{ - ID: e.ID, - Email: e.Email.Ciphertext, - EmailEq: e.Email.Equality, - EmailMatch: e.Email.Match, - Age: e.Age.Ciphertext, - AgeEq: e.Age.Equality, - AgeOre: e.Age.Ore, - Attrs: e.Attrs, - Notes: e.Notes, - } -} - -func (s userRowShape) encrypted() EncryptedUser { - return EncryptedUser{ - ID: s.ID, - Email: EncryptedUserEmail{Ciphertext: s.Email, Equality: s.EmailEq, Match: s.EmailMatch}, - Age: EncryptedUserAge{Ciphertext: s.Age, Equality: s.AgeEq, Ore: s.AgeOre}, - Attrs: s.Attrs, - Notes: s.Notes, - } -} - -var UserRows = gensupport.Rows(userPlan, - func(e EncryptedUser) UserRow { return UserRow(userRowShapeOf(e)) }, - func(r UserRow) EncryptedUser { return userRowShape(r).encrypted() }, -) - -var SQLCUsers = gensupport.Rows(userPlan, - func(e EncryptedUser) userdb.User { return userdb.User(userRowShapeOf(e)) }, - func(r userdb.User) EncryptedUser { return userRowShape(r).encrypted() }, -) diff --git a/docs/sdk-design-principles.md b/docs/sdk-design-principles.md new file mode 100644 index 000000000..55cd69051 --- /dev/null +++ b/docs/sdk-design-principles.md @@ -0,0 +1,217 @@ +# Language SDK design principles + +These principles apply when you architect, design or build a language SDK for Stack Encrypt. +The first part applies to every language. +The second part applies to the Go SDK. +[ADR-0002](adr/0002-language-sdk-design-principles.md) records the decision to adopt them. + +## Terms + +- **Engine:** the Rust code that encrypts, decrypts and derives search terms. +- **Binding:** the FFI or WASI interface between the engine and a target language. +- **Language SDK:** what users of the target language work with day to day. +- **Declaration:** the statement of how each field is encrypted: its context, and its indexes. + The engine calls a saved declaration a plan. + +## When two principles disagree + +Apply them in this order: + +1. The engine does the work, and the bytes are the same in every language. +2. A mistake is found at the earliest stage. +3. The SDK hides what the user cannot act on. +4. The SDK reads like the host language. + +## Principles for every language SDK + +### 1. One engine, and the SDK never executes + +All encryption, decryption and term derivation happens in the engine. +An SDK declares what to do and sends that across the binding as data. +An SDK does not have its own loop over fields, its own batching, or its own sealing. + +- An SDK sends the full declaration, with every field in it. +- An SDK can skip the value of a field when the engine computes nothing from it and the host's types guarantee the value is present. +- An SDK can assemble a wire format in the host language only when a cross-language test compares the bytes. + +### 2. A language SDK takes its host language's shape + +An SDK is designed for its language. +It is not a translation of the Rust API. + +These must be the same in every SDK: + +- the bytes for the same declaration; +- the words for engine behaviour that a user writes, such as `equality`, `match`, `ore`, `passthrough` and `context`; +- fail-closed behaviour. + +Each engine capability is reachable from the SDK, or the SDK's documentation lists it as not supported. + +An SDK can hide an engine concept when the host language has its own way to express it. +The SDK's documentation then names the engine's term once. + +### 3. Run each check at the earliest stage the language allows + +The stages are, in order: the compiler, a build or generate step, CI, startup, and the running program. +The running program checks only what depends on data. +An SDK should not use a stage when its language offers an earlier one. + +For a dynamic language, the SDK ships hints, suggestions and rules for the language's type checkers and linters. +Users and LLMs then get a warning as early as possible. + +### 4. One declaration serves the write, the query and the read + +How a field is encrypted is declared once. +Encrypting a value, building a search for it and decrypting it all use that declaration. +No call takes a context or an index choice of its own. + +What changes from one caller to the next, such as a tenant, attaches to the cipher and not to each call. + +This principle covers one version of a declaration. +A change to a declaration over time needs its own design. + +### 5. Batch by default + +One call from the user makes one request to the key service, however many values it carries. +The natural way to call an SDK is the efficient way. + +- An SDK does not offer a single-value form beside the batch form. +- An SDK can put operations on different types in one request. + +### 6. A second way in has to earn its place + +An SDK has one way to declare what is encrypted, and its tooling has one way to be driven. +A second way must pass all four tests: + +1. It serves a need that the first way cannot meet. +2. It produces the same output as the first way, through the same code. +3. It keeps every guarantee of the first way, at the same stage. +4. Someone owns it: it has documentation, tests and a place in the supported set. + +There is one SDK for each language. + +### 7. State the design, and run its claims + +A design document says what the design is. +The reasons go in the commit that makes the change, and in ADRs. +A design document has no history and no rejected options. + +A claim that the design depends on is run before it is written down. +The document lists what was compiled, what was run and what is untested. + +Examples are complete programs. + +### 8. Usage first, then reference + +Documentation opens with the steps a new user follows, in order, to a working result. +The reference comes after. +The same order applies inside each section. + +- Usage steps are tested, so a step that stops working fails CI. +- The agent skills under `skills/` follow the same rules. +- Sentences have one idea and 25 words or fewer, in the active voice, with one word for one thing. + +## Principles for the Go SDK + +### 1. Find mistakes at the earliest stage + +The stages for Go are: the compiler, `go generate`, CI, and the running program. +The running program finds only a mistake that depends on data. + +### 2. Return a concrete type that matches the input + +A caller who encrypts a value gets a named Go type whose fields have the names of the input's fields. +The caller reaches every output through a field. +There is no type assertion, no map lookup and no string key. + +With separate columns, every sealed field gets its own struct, including a field with one output. + +### 3. No `Must` function and no panic + +No function in the SDK or in generated code panics for a declaration. +No function has a name that starts with `Must`. +A mistake is a build failure, a `go generate` failure, or a returned error. + +### 4. Generate code with a Go tool + +A generator writes what Go's type system cannot express, before the program is built. +The generator is a Go command that `go generate` runs. +The SDK and its generated code do not use reflection. + +### 5. Check a type the SDK does not own at the same stage + +A struct that another tool or package owns gets the same checks, at the same stage, wherever Go allows it. +Where Go does not allow it, the check moves one stage later. +A struct from another package with an unexported field is that case. + +With one EQL column for each field, this applies to sqlc, to a type in another package, and to a program that uses separate columns. + +### 6. One call shape for every type + +The generator writes `Encrypt`, `Decrypt` and `Fields` into the user's package. +Every type has the same call shape: `users.Encrypt(ctx, cipher, people)`. +`Encrypt` and `Decrypt` take a slice and return a slice. +A field's own `Encrypt` takes one value. + +### 7. The user never sees the plan + +A user declares what to encrypt with tags, and works with generated types and functions. +No type, function, variable or method that a user touches is called a plan or returns one. +This rule covers names and API surface. + +### 8. Go idiom decides the shape + +This principle comes after the guarantees and after hiding the plan. + +- `ctx` is the first parameter, and it is never stored. +- Every call returns its result, and nothing needs a second call to finish. +- Errors are values that `errors.Is` and `errors.As` read. +- Names follow Go conventions. + +### 9. Fail closed + +When the SDK cannot tell whether a field is encrypted, it stops. +It does not store plaintext, skip a field or guess. + +- Every exported field carries a `stash` tag. +- One tag on an embedded struct decides for all of its fields. +- An unexported field with no tag is ignored, with three notices. + The generator prints one, the generated file holds one as a comment, and the running program prints one to stderr. + The tag `stash:"-"` on the field states the choice and stops the notices. + +### 10. What is encrypted is fixed before the program ships + +Which fields are encrypted, with which indexes and under which context, is in source control before the build. +A change to it shows as a change to a committed file that a reviewer reads. +So generated files are committed, and CI fails when one is out of date. + +### 11. Fit the way Go code reaches a database + +The SDK is tested with `database/sql`, pgx, sqlc and GORM. +A library that uses `driver.Valuer` and `sql.Scanner` works with the SDK, and a failure with one is a bug. + +- The SDK leads with one EQL column for each field. +- Encryption happens before the database library gets the value. +- The generator copies other libraries' tags to the generated type. + +### 12. Never put plaintext in an error + +No error, warning or log line from the SDK or its generated code holds a plaintext value. +A generated type hides its sealed fields when a program prints it. + +For the struct the user wrote, the SDK warns from the generator and from the running program. +It writes print methods on request, and a `go vet` check reports a print of the struct. + +### 13. A name says what the thing is for + +No abbreviation that a reader must decode, and no name that repeats its package. +The words in a tag are the Rust API's words. +The generator does not guess a plural. + +## Not yet decided + +- The policy package's approach. +- How SDK tools get the engine's rules. +- Query building in Go. +- The design of the `go vet` check. +- A change to a declaration over time. From edcba1a6b43ebd6c2b718ceeb270c7876e30ec59 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 17:14:36 +1100 Subject: [PATCH 10/22] docs(plans): apply the SDK principles to the Go SDK design Rewrites the Go section of the plan and every example to follow docs/sdk-design-principles.md. The plan states the design; the reasons are here, one for each change. The user never sees a plan. A program now calls generated functions in its own package: users.Encrypt, users.Decrypt and users.Fields. That removes stackencrypt.Encrypt, the Planned interface, the StashPlan methods and the exported plan variables. The package function could not serve a type from another package or a model, so it needed a second call shape beside it; a plan variable put "plan" at every call site. Generated functions give one call shape for all three cases, add nothing to the user's types, and only generated code can provide them. A -name flag tells two structs in one package apart. One EQL column for each field leads. encrypt_into names an EQL type and the generated field holds one value. The generated type is then flat, so database/sql, pgx, sqlx and GORM take it as it is, and sqlc's row struct converts to it directly. Separate columns stay as the second layout, with a model named by -record (the flag was -row, and the term was "storage struct"). Every sealed field gets its own struct in the separate-columns layout, including a field with one output. Flattening meant that adding an index later changed how every caller read the field. An opaque struct replaces Cipher.Encrypt and DecryptValue. Sealing a whole value was the last call that took a context by hand and returned an untyped ciphertext. As a tag, the context is checked by the generator and the write and the read cannot differ. Context extension moves to the cipher. Passed on each call, it could be left off the query, which then matched nothing with no error. Batch2 and Batch3 put two or three types in one ZeroKMS request. Batching across types is part of the value of the product, so it is in the design and no longer an open question. Go has no variadic generics, so there is one function for each count. Fail closed is spelled out for three cases: one tag on an embedded struct decides for all its fields, and the generated type embeds the same struct so its own tags still apply; other libraries' tags are copied; an unexported field with no tag is ignored with three notices, and stash:"-" states the choice. Printing. Generated types hide their sealed fields. For the struct the user wrote: a generator warning, a run-time warning, a -redact flag, and a go vet check. The full declaration crosses the binding, with only passthrough values skipped. The last commit left passthrough fields out of the declaration, which hid them from the engine. The examples change to match. The blocklist example and the separate-columns sqlc example are removed; accounts (embedded struct, unexported field, -redact) is added; documents is an opaque struct; contacts shows a type from another package with separate columns and a model. Six open questions replace the old one. The EQL package name, its type names and the values of encrypt_into are placeholders until #1062. Checked. Every Go file passes `go vet ./...`, and the generate program passes under `-tags stashgen`, against the uncommitted stub of the SDK; gofmt reports nothing. Seven mutations each fail `go build` as the design says: a field added to the tagged struct, to the model, and to crm.Contact; a column type changed in sqlc's row struct; a generated file from another version; a read of an undeclared output; a field added to Individual. The generate program still builds in that last case. The README lists what was run and what was not. The amended text passes slipstream's checks with 0 findings (214 sentences, the longest 24 words), and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 532 ++++++++++-------- docs/plans/2026-10-04-plan-builder/README.md | 56 +- .../accounts/account.go | 39 ++ .../accounts/account_stash.go | 118 ++++ .../contacts/contacts.go | 59 +- .../contacts/contactstash_stash.go | 193 +++++++ .../documents/document_stash.go | 89 +++ .../documents/documents.go | 37 +- .../individuals/individual_stash.go | 146 +++++ .../individualstore/store.go | 10 +- .../internal/userdb/db.go | 31 + .../internal/userdb/models.go | 17 + .../internal/userdb/query.sql.go | 153 +++++ docs/plans/2026-10-04-plan-builder/main.go | 35 +- .../sqlc/eql-domains.sql | 1 + .../2026-10-04-plan-builder/sqlc/query.sql | 15 +- .../2026-10-04-plan-builder/sqlc/schema.sql | 3 +- .../2026-10-04-plan-builder/sqlc/sqlc.yaml | 17 +- .../users/gormstore.go | 48 +- .../2026-10-04-plan-builder/users/model.go | 16 +- .../users/sqlcstore.go | 84 +-- .../2026-10-04-plan-builder/users/sqlstore.go | 85 +-- .../users/user_stash.go | 182 ++++++ 23 files changed, 1485 insertions(+), 481 deletions(-) create mode 100644 docs/plans/2026-10-04-plan-builder/accounts/account.go create mode 100644 docs/plans/2026-10-04-plan-builder/accounts/account_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/documents/document_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/db.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/models.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go create mode 100644 docs/plans/2026-10-04-plan-builder/users/user_stash.go diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 27a4b2207..9170fbb06 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -40,8 +40,8 @@ the tagged encoding). After this work there is one front end, a **plan builder**, with three authors: a person writing a chain, the derive writing it from attributes, and -the FFI writing it from data. The Go binding uses the same plans in Go's own -call shape. The combinators stay public as the extension point. Everything a Rust caller, the +the FFI writing it from data. The Go SDK declares the same thing with struct tags and +generated code. The combinators stay public as the extension point. Everything a Rust caller, the derive, Go and the guest produce for the same declaration is the same bytes by construction, because it is the same code. @@ -101,8 +101,8 @@ split was the problem, not the function names: await nothing has touched a key. 2. **The context slot is named `context`.** It is the honest name for what is supplied. `under` was rejected (borrowed from `Encryption::under`, and - opaque to a reader). The Go binding has no `context` method; see "The Go - binding". + opaque to a reader). The Go SDK has no `context` method; see "The Go + SDK". 3. **Field-by-field is a modifier, `.fields()`, not a second verb.** `columns` was rejected as database-centric; the SDKs are not only for databases. `fields` is the derive docs' own phrase ("field by field") and @@ -501,12 +501,15 @@ them on the struct and lowers them through `spec()`. The data form is what crosses the FFI and what a saved plan in Go holds; the Rust side never loses the type. -## The Go binding +## The Go SDK -Go uses the same plans as Rust, in Go's own call shape. -A struct's `stash` tags declare its plan, and a generator, `stashgen`, writes the encrypted type and the plan from them. -Every operation takes `ctx` first and the cipher second, and it returns a concrete type. -The compiler checks each field, each index and each storage struct. +The Go SDK is what a Go program uses: struct tags, a generator, and the code the generator writes. +The binding is the WASI interface between that code and the Rust engine. +The SDK follows [the language SDK design principles](../sdk-design-principles.md). + +A struct's `stash` tags declare how each field is encrypted. +A generator, `stashgen`, writes the encrypted type and its functions from the tags. +A program calls those functions, and it never builds or names a plan. [The Go examples](2026-10-04-plan-builder/README.md) show each part below as a complete program. ```go @@ -514,64 +517,27 @@ The compiler checks each field, each index and each storage struct. type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt,index=equality;match"` - Age uint32 `stash:"age,encrypt,index=equality;ore"` - Notes string `stash:"notes,encrypt"` + Email string `stash:"email,encrypt_into=TextSearch"` + Age int32 `stash:"age,encrypt_into=IntegerOrd"` } cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) -encrypted, err := stackencrypt.Encrypt(ctx, cipher, people) // []EncryptedUser, one ZeroKMS request -users, err := stackencrypt.Decrypt(ctx, client, encrypted) // []User -term, err := UserFields.Email.Equality(ctx, cipher, "bob@example.com") // EqualityTerm - -_ = encrypted[0].Email.Equality // EqualityTerm -_ = encrypted[0].Email.Ore // does not compile: email declares no ORE index +encrypted, err := users.Encrypt(ctx, cipher, people) // []users.EncryptedUser, one ZeroKMS request +people, err := users.Decrypt(ctx, cipher, encrypted) // []users.User +query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com") // eql.TextSearchQuery ``` -### Struct tags - -The `stash` tag on each field declares what the plan does with it: - -| Tag | Field verb | -|---|---| -| `` _ struct{} `stash:"context=users"` `` | the plan's context | -| `stash:"notes,encrypt"` | `Encrypt` | -| `stash:"email,encrypt,index=equality;match"` | `EncryptIndex` | -| `stash:"attrs,index=json"` | `Index` | -| `stash:"id,passthrough"` | `Passthrough` | -| `stash:"-"` | `Omit` | - -The first part of a tag is the field's name in the plan, which is the column name in a database. -The index names are `equality`, `match`, `ore`, `ope` and `json`. -An index takes its options in the tag, in parentheses after its name. -The fields of an embedded struct are fields of the outer struct. -Only `stashgen` reads these tags, and no function reads them at run time. - -### stashgen +### Use the SDK -`stashgen` is a Go command that reads the `stash` tags of a struct and writes its encrypted type. -It is at `languages/golang/cmd/stashgen`. - -#### Use stashgen - -1. Add the tool to your module. +1. Add the generator to your module. This needs Go 1.24 or later. ```sh go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen ``` -2. Put `stash` tags on the struct, and a `go:generate` comment beside it. - - ```go - //go:generate go tool stashgen -type User - type User struct { - _ struct{} `stash:"context=users"` - ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt,index=equality;match"` - } - ``` +2. Put a `stash` tag on every exported field of the struct, and a `go:generate` comment beside it. 3. Run the generator. It writes `user_stash.go` beside the struct. @@ -582,111 +548,215 @@ It is at `languages/golang/cmd/stashgen`. 4. Commit the generated file. -5. Call the generated code. +5. Call the generated functions where you write and read. ```go - encrypted, err := stackencrypt.Encrypt(ctx, cipher, []User{alice}) - term, err := UserFields.Email.Equality(ctx, cipher, "bob@example.com") + encrypted, err := users.Encrypt(ctx, cipher, people) + people, err := users.Decrypt(ctx, cipher, encrypted) ``` -6. Run the generator again after each change to the struct or to a tag. +6. Store the encrypted type. + Each field is one column, so `database/sql`, pgx, sqlx and GORM take it as it is. + +7. Run the generator again after each change to the struct or to a tag. A change to the fields of the struct stops the build until you do. -7. In CI, run the generator and fail when a generated file changes. +8. In CI, run the generator and fail when a generated file changes. ```sh go generate ./... && git diff --exit-code ``` -Three more cases use the same steps with one more flag or one more file: - -- To encrypt into a GORM model, an sqlc model or another storage struct, add `-row`. See "Storage structs". -- To encrypt a type from another package, add `-for`. See "Types in another package". -- To decide the plan from a policy, write a generate program. See "Plans from a policy". - The rest of this section is the reference. -#### Flags +### Struct tags -| Flag | Meaning | +The first part of a tag is the field's name, which is the column name in a database. + +| Tag | Meaning | |---|---| -| `-type T` | The struct that carries the `stash` tags. Required. | -| `-row Name=R` | A storage struct `R`, and the name of the variable that encrypts into it. Any number. | -| `-row Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` declares the tags. | -| `-for P.F` | `T` declares the plan for `F`, a type in another package. | -| `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | +| `` _ struct{} `stash:"context=users"` `` | the context of every field in the struct | +| `stash:"email,encrypt_into=TextSearch"` | seal the field into one EQL value, with the indexes that EQL type has | +| `stash:"notes,encrypt"` | seal the field, with no index | +| `stash:"email,encrypt,index=equality;match"` | seal the field, and derive each index beside it | +| `stash:"attrs,index=json"` | derive the index alone | +| `stash:"id,passthrough"` | store the field as it is | +| `stash:"-"` | leave the field out | +| `` _ struct{} `stash:"context=documents,opaque"` `` | seal the struct as one value | -#### How stashgen reads a package +The index names are `equality`, `match`, `ore`, `ope` and `json`. +An index takes its options in the tag, in parentheses after its name. +These words are the same as the Rust API's words for the same behaviour. -The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text. -It runs none of the package's code. -It ignores its own output file when it loads the package, so a stale file does not stop it. -The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. +An `opaque` struct has no tags on its fields. +Nothing inside it can be read or searched on its own. +A value with no struct around it is a struct with one field. + +### Columns + +A field maps to its columns in one of two layouts. -#### What stashgen writes +**One EQL column for each field** is the layout to use. +`encrypt_into` names an EQL type, and the generated field holds one EQL value. +The value has the ciphertext and every term, and Postgres searches it through EQL's operators. +The generated type is then flat: one field, one column. + +**Separate columns** is the other layout. +`encrypt,index=...` gives one column for the ciphertext and one for each term. +The generated field is then a struct with one field for each output, such as `Email.Ciphertext` and `Email.Equality`. +Every sealed field gets such a struct, including a field with one output. +A library that maps one struct field to one column needs a model for this layout. + +### What stashgen writes For `-type User`, the file `user_stash.go` holds: - **`EncryptedUser`.** - It has one field for each field of `User` that the plan stores. - A field with one output has that output's type, such as `Ciphertext`. - A field with more outputs has its own struct, such as `EncryptedUserEmail`, with one field for each output. -- **The plan.** - The file declares it as data, and converts values with plain assignments. - Generated code uses no reflection. -- **A `StashPlan` method on `User` and on `EncryptedUser`.** - The method gives each type the `Planned` interface. - `stackencrypt.Encrypt` and `stackencrypt.Decrypt` then find the plan and the result type from the value. -- **`UserFields`.** + It has one field for each field of `User` that is stored, with the same name. + A passthrough field keeps its Go type. +- **`Encrypt` and `Decrypt`.** + `Encrypt` takes a `[]User` and returns a `[]EncryptedUser`. + `Decrypt` goes the other way. +- **`Fields`.** It has one entry for each sealed field. - An entry encrypts one value of that field, and it has a query method only for an index the field declares. + An entry encrypts one value of that field, and it has a query method only for what the field declares. +- **`Encryption` and `Decryption`.** + They describe the same work without running it, for a batch. +- **Print methods on `EncryptedUser`.** + `String` and `LogValue` print the passthrough fields and hide the sealed ones. +- **The declaration.** + It is data that only generated code uses, and no function a program calls names it. The file also converts `User` to a copy of its fields, so a change to the fields of `User` stops the build. -The generated code copies a passthrough field itself, so that field does not cross into the guest. +Generated code uses no reflection. See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_stash.go`](2026-10-04-plan-builder/users/user_stash.go). -#### What stashgen refuses +### Calls -`stashgen` stops with an error, and writes no file, for each of these: +| Call | Returns | +|---|---| +| `users.Encrypt(ctx, c *stackencrypt.Cipher, vs []User)` | `[]EncryptedUser` | +| `users.Decrypt(ctx, d stackencrypt.Decrypter, es []EncryptedUser)` | `[]User` | +| `users.Fields.Email.Encrypt(ctx, c, v string)` | the field's generated type, for an update of one column | +| `users.Fields.Email.Query(ctx, c, v string)` | an EQL query value, for a field with `encrypt_into` | +| `users.Fields.Email.Equality`, `.Match`, `.Ore`, `.Ope` | one term, for a field with `index=` | +| `users.Fields.Attrs.Contains(ctx, c, v)` | a JSON containment query | +| `users.Encryption(vs []User)`, `users.Decryption(es []EncryptedUser)` | a `stackencrypt.Operation`, for a batch | +| `stackencrypt.Batch2(ctx, c, a, b)`, `Batch3` | the result of each operation | -- an exported field with no `stash` tag; -- a tag that does not parse, or two fields with one name; -- a struct with no `context=` field; -- an index that does not apply to the field's Go type, such as `match` on a `uint32`; -- a field type that the engine cannot seal; -- a `passthrough` field that has an index. +Every call also returns an `error`. +`Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. +The result has one element for each element of the input, in the same order. +For one value, pass a slice with one element. -#### Storage structs +A field entry's `Encrypt` and its query methods take one value. +A term derives with no request, so there is nothing to batch. -A storage struct has one field for each column. -It can come from any tool, such as GORM, sqlc, ent or sqlboiler, because `stashgen` reads Go types and not a tool's own format. -Each of its fields carries a `stash` tag that names one output: -`stash:"email"` is the ciphertext of `email`, and `stash:"email,equality"` is its equality term. -For sqlc, a `go_struct_tag` override puts the tag on the generated model. +`Batch2` and `Batch3` run operations on two or three types in one ZeroKMS request. +Each returns one typed result for each operation. + +```go +encryptedUsers, encryptedContacts, err := stackencrypt.Batch2(ctx, cipher, + users.Encryption(people), contacts.Encryption(list)) +``` + +`*Client` and `*Cipher` both implement `Decrypter`. +A `*Client` decrypts each value under the keyset that sealed it. +A `*Cipher` also refuses a value from another keyset, with `ErrForeignKeyset`. + +### The cipher + +The cipher holds what changes from one caller to the next: the keyset, and any extension of the context. + +```go +cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")).Extend("tenant-42") +``` + +`Extend` returns a cipher that extends the context of every field, in every call through it. +No call takes a keyset or a context. +So the write, the query and the read cannot use different ones. + +### Names + +In a package with one tagged struct, the generated names are `Encrypt`, `Decrypt` and `Fields`. +The package name says what they encrypt: `users.Encrypt`. + +A package holds one function named `Encrypt`. +For a second struct in the package, `-name Account` gives `EncryptAccount`, `DecryptAccount` and `AccountFields`. +The flag works on any struct, so both can carry a name. +The generator stops when two structs in a package would both write `Encrypt`. + +### Embedded structs, unexported fields and other tags + +**An embedded struct of your own** adds its tagged fields to the outer struct. + +**An embedded struct from another package** cannot carry tags, so one tag on the embedded field decides for all of its fields: + +```go +type Account struct { + _ struct{} `stash:"context=accounts"` + gorm.Model `stash:",passthrough"` + Email string `stash:"email,encrypt_into=TextEq" gorm:"uniqueIndex"` +} +``` + +`stash:",passthrough"` stores every field of the embedded struct as it is, and `stash:"-"` leaves them all out. +The generated type embeds the same struct, so its own tags still apply. +With no tag on the embedded field, the generator stops and names the fields. + +**Tags for other libraries**, such as `gorm`, `db` and `json`, are copied to the same field of the generated type. + +**An unexported field with no `stash` tag** is ignored, with three notices: -For `-row UserRows=UserRow`, `stashgen` writes `UserRows`, a `RowPlan[User, UserRow]`. -Its `Encrypt` returns a `[]UserRow`, and its `Decrypt` takes one. -The generator refuses a storage struct that has a field with no tag. -It also refuses one that has no field for an output of the plan. +- `stashgen` prints a warning. +- The generated file names the field in a comment. +- The program prints a warning to stderr, once for each type, on first use. -Some tools give no way to put a tag on the struct they write. -For those, `-row Name=R:D` names a struct `D` in your own package. -`D` has the same field names as `R`, and its fields carry the tags. +`stash:"-"` on the field states the choice, and all three notices stop. +See [`accounts/account.go`](2026-10-04-plan-builder/accounts/account.go). -The generated file also holds a copy of the storage struct's fields, and it converts between the copy and the struct. +### Printing + +A generated type hides its sealed fields when a program prints or logs it. + +The struct you wrote is not protected. +A `User` that a program prints or logs shows every field. +The SDK gives four tools for that: + +- `stashgen` warns when a struct has sealed fields and no `String` and `LogValue` methods. +- The program prints the same warning to stderr, once for each type, on first use. +- `-redact` makes `stashgen` write those two methods on the struct. +- A `go vet` check reports a struct with sealed fields that is passed to `fmt`, `log` or `slog`. + +No warning, error or log line from the SDK holds a plaintext value. +Each names the type and the field only. + +### Models + +A model is a struct with one field for each column, such as a GORM model. +With one EQL column for each field, the generated type is already such a struct, and no model is needed. + +sqlc writes its own row struct in every case. +With one EQL column for each field, that struct has the same fields as the generated type, and Go converts one to the other. +A change to either struct stops the build. +See [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go). + +With separate columns, `-record Rows=ContactRow` names a model. +Each field of the model carries a `stash` tag that names one output: +`stash:"email"` is the ciphertext of `email`, and `stash:"email,equality"` is its equality term. +`stashgen` writes `EncryptRows` and `DecryptRows`, which return and take the model. + +The generated file holds a copy of the model's fields, and it converts between the copy and the model. Go allows that conversion only while the two have the same fields, with the same types, in the same order. -So a change to the storage struct stops the build until `go generate` runs again. +So a change to the model stops the build until `go generate` runs again. -That conversion needs a struct whose fields are all exported. -A struct from another package with an unexported field, such as a protobuf message or an ent entity, cannot convert. -For such a struct the generated file assigns each field by name. -The compiler then finds a removed field and a field with a new type, and CI finds an added field. -See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go) and [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go). +For a model that cannot carry tags, `-record Rows=R:D` names a struct `D` in your own package that declares them. +See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). -#### Types in another package +### Types in another package -A type in another package cannot carry `stash` tags, and it cannot get a `StashPlan` method. -For such a type, a struct in your own package declares the plan: +A type in another package cannot carry `stash` tags. +For such a type, a struct in your own package declares them: ```go //go:generate go tool stashgen -type contactStash -for crm.Contact @@ -701,13 +771,47 @@ type contactStash struct { `stashgen` matches each field to the field of `crm.Contact` with the same name and type. It refuses a field of `crm.Contact` that the struct does not name. -It writes `EncryptedContact`, `ContactFields` and an exported `ContactPlan`, a `RowPlan[crm.Contact, EncryptedContact]`. +The generated functions have the same names and shapes as for a struct of your own. The file converts `crm.Contact` to a copy of its fields, so a change to `crm.Contact` stops the build. -See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). -#### Plans from a policy +That conversion needs a struct whose fields are all exported. +A struct from another package with an unexported field, such as a protobuf message, cannot convert. +For such a struct the generated file assigns each field by name. +The compiler then finds a removed field and a field with a new type, and CI finds an added field. + +### stashgen reference + +`stashgen` is a Go command at `languages/golang/cmd/stashgen`. + +| Flag | Meaning | +|---|---| +| `-type T` | The struct that carries the `stash` tags. Required. | +| `-name N` | Write `EncryptN`, `DecryptN` and `NFields`. | +| `-for P.F` | `T` declares the tags for `F`, a type in another package. | +| `-record Name=R` | A model `R` for separate columns. Writes `EncryptName` and `DecryptName`. Any number. | +| `-record Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` declares them. | +| `-redact` | Write `String` and `LogValue` methods on `T`. | +| `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | + +The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text. +It runs none of the package's code. +It ignores its own output file when it loads the package, so a stale file does not stop it. +The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. + +`stashgen` stops with an error, and writes no file, for each of these: + +- an exported field with no `stash` tag; +- a tag that does not parse, or two fields with one name; +- a struct with no `context=` field; +- an index or an EQL type that does not apply to the field's Go type, such as `match` on an `int32`; +- a field type that the engine cannot seal; +- a `passthrough` field that has an index; +- a model with a field that has no tag, or with no field for an output; +- two structs in one package that would both write `Encrypt`. + +### Declarations from a policy -A policy decides a plan from the data categories that a schema gives each field. +A policy decides what to encrypt from the data categories that a schema gives each field. The policy is Go code, so a program must run it. That program is a generate program that you own, and `go generate` runs it: @@ -728,7 +832,7 @@ The application never runs the policy. - A field that no rule decides stops `go generate`, with the field's name and its annotations. - A change to the policy changes the generated file, so a reviewer reads what the change encrypts. -- A field that the policy stores as plaintext is a plain field of the generated type. +- A field that the policy stores as plaintext is a passthrough field of the generated type. The generate program imports the package that holds the type. So that package must build when the generated file is stale. @@ -736,146 +840,90 @@ The generated file carries the build constraint `!stashgen`, and the generate pr Code that uses the generated names goes in another package. See [`policy/policy.go`](2026-10-04-plan-builder/policy/policy.go), [`cmd/genplans/main.go`](2026-10-04-plan-builder/cmd/genplans/main.go) and [`individualstore/store.go`](2026-10-04-plan-builder/individualstore/store.go). -#### When the compiler finds a mistake +### When a mistake is found + +The SDK finds each mistake at the earliest of these stages: the compiler, `go generate`, CI, and the running program. +The running program finds only a mistake that depends on data. | Mistake | Found by | |---|---| -| A read of an output that the plan does not declare, such as `enc.Email.Ore` | the compiler | -| A query for an index that the field does not declare | the compiler | -| `stackencrypt.Encrypt` with a type that has no plan | the compiler | -| A change to a storage struct, or to a type in another package | the compiler | -| A field added to a struct from another package that has an unexported field | CI | +| A read of an output that the field does not declare | the compiler | +| A query that the field does not declare | the compiler | +| An encrypted value passed where another type's is expected | the compiler | | A field added to, removed from or retyped in the tagged struct | the compiler | -| A tag that does not parse, or a field that no policy rule decides | `go generate` | +| A change to a model, to sqlc's row struct, or to a type in another package | the compiler | | A generated file and a library from versions that do not agree | the compiler | +| A tag that does not parse, an untagged field, or a field that no policy rule decides | `go generate` | +| A field added to a struct from another package that has an unexported field | CI | | A change to a tag or to a policy, with no `go generate` run | CI | +| A value that does not decrypt, or that decrypts to another type | the running program, as an error | CI runs `go generate ./...` and fails when a generated file differs from the committed file. -The compiler does not make those two checks. - -### Plan types - -`RowPlan[T, R]` is the one plan type in `stackencrypt`. -It encrypts a `T` into an `R` and decrypts an `R` into a `T`. -Only generated code makes one. -A program reaches it through `stackencrypt.Encrypt`, or through a generated variable such as `UserRows`. -A plan does not change after it is built, and any number of goroutines can use it at the same time. -No function that a program calls has a name that starts with `Must`. -No function in the binding panics for a plan, in generated code or in any other code. +No function in the SDK or in generated code panics for a declaration, and none has a name that starts with `Must`. -### Support for generated code +### What crosses the binding -The package `stackencrypt/gensupport` holds what only generated code calls: +The engine does all encryption, decryption and term derivation. +Generated code sends the engine the full declaration: every field, including each passthrough field and each field left out. +It sends the value of each sealed field. +It does not send the value of a passthrough field, and it copies that value to the generated type itself. -- `Plan` declares the fields and their indexes as data. - `EncryptIndex` and `Index` take one index and then any number more, so an empty index set does not compile. -- `Generated[T, E]` holds the plan and four conversion functions, and `New` makes a `RowPlan[T, E]` from it. -- `Rows` makes a `RowPlan[T, R]` for a storage struct from a generated plan and two conversions. -- `Field[V]` is the plan for one field, and a generated field entry holds one. -- `Values` holds plaintext values by field name, and `Get[V]` reads one as the Go type `V`. -- `Record` holds the outputs of each field by name. +Generated code assembles an EQL value from the engine's ciphertext and terms. +A cross-language fixture guards those bytes: encode in Rust, decode and encode again in Go, and compare. -No function in this package panics. -`New` only stores what it is given. -The engine checks a plan when an operation first sends it to the guest, and that operation returns the error. - -A generated file and the library must be from versions that agree. -Each generated file names a constant, such as `gensupport.GeneratedVersion1`, that only such a library declares. +The package `stackencrypt/gensupport` holds what only generated code calls. +No function in it panics. +Each generated file names a constant, such as `gensupport.GeneratedVersion1`, that only a library of an agreeing version declares. So a file from another version does not compile. -### The policy package - -`plan.Policy`, `plan.When`, `plan.FirstOf` and `plan.ForMessage` keep their form. -`plan.EQL` takes `stackencrypt.Index` values. -`stashgen.Generate` runs the policy, as "Plans from a policy" describes. - -### Indexes - -`Index` is an interface that only `stackencrypt` implements. -`Equality`, `Ore` and `Ope` are values. -`Match(opts ...MatchOption)` and `JSON(opts ...JSONOption)` are constructors that take the index's options. - -### Operations - -| Call | Returns | -|---|---| -| `Encrypt(ctx, c *Cipher, vs []T, opts ...Option)`, for a `Planned` type | `[]E`, where `E` is the generated type | -| `Decrypt(ctx, d Decrypter, es []E, opts ...Option)` | `[]T` | -| `RowPlan[T, R]`: the same two, as methods | `[]R` in place of `[]E` | -| A generated field entry, such as `UserFields.Email`: `Encrypt` | the field's generated type | -| A generated field entry: `Equality`, `Match`, `Ore`, `Ope` | `EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` | -| A generated field entry: `Contains`, `EqualAt` | `JSONQuery` | -| A generated field entry: `Selector` | `JSONSelector` | -| `Cipher.Encrypt(ctx, v any, c Context, opts ...Option)` | `Ciphertext`, one tree | -| `DecryptValue[T](ctx, d Decrypter, ct Ciphertext, c Context, opts ...Option)` | `T` | - -Every call also returns an `error`. -`Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. -The result has one element for each element of the input, in the same order. -For one value, pass a slice with one element. -There is no second form for one value. - -`NewContext(bytes)` makes a `Context` from raw bytes, the same bytes part that Rust accepts. -See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go) and [`documents/documents.go`](2026-10-04-plan-builder/documents/documents.go). - -`*Client` and `*Cipher` both implement `Decrypter`. -A `*Client` decrypts each leaf under the keyset that sealed it. -A `*Cipher` also refuses a leaf from another keyset, with `ErrForeignKeyset`. - -The cipher holds the keyset: `client.Keyset(stackencrypt.KeysetName("tenant-42"))`. -No call takes a keyset option. - -`ExtendContext(parts ...any)` is the one `Option`. -It extends the context of every field in the plan. -The write, the query and the read must pass the same parts. -See [`users/extend.go`](2026-10-04-plan-builder/users/extend.go). - -### Output types - -`Ciphertext`, each term type, `JSONDocument`, `JSONQuery` and `JSONSelector` implement `driver.Valuer`. -`Ciphertext`, each term type and `JSONDocument` also implement `sql.Scanner`. -So a field of a generated type, or of a storage struct, goes straight to `ExecContext` and to `Scan`. - ### Errors -The generator and the compiler find a mistake in a plan, so no operation returns an error for one. -An operation returns an error for a key, for the network, or for stored data: +The generator and the compiler find a mistake in a declaration, so no call returns an error for one. +A call returns an error for a key, for the network, or for stored data: -- `ErrForeignKeyset`: a `*Cipher` got a leaf that another keyset sealed. -- A ciphertext that does not decrypt under the plan's context. +- `ErrForeignKeyset`: a `*Cipher` got a value that another keyset sealed. +- A ciphertext that does not decrypt under the field's context. - A decrypted value that is not the field's Go type. Use `errors.Is` and `errors.As` to read it. -No error holds a plaintext value. -### Databases and ORMs +### Databases -- **database/sql:** pass the fields of the generated type to `ExecContext`, and `Scan` into them. +The SDK is tested with `database/sql`, pgx, sqlc and GORM. + +Every stored type implements `driver.Valuer` and `sql.Scanner`. +A library that uses those two interfaces works with the SDK. +A failure with such a library is a bug in the SDK. + +Encryption happens before the database library gets the value. +A driver's value hook gets no `context.Context` and one field at a time, so it cannot batch a request. + +- **database/sql and pgx:** pass the fields of the generated type to `ExecContext`, and `Scan` into them. See [`users/sqlstore.go`](2026-10-04-plan-builder/users/sqlstore.go). -- **GORM:** the store encrypts and decrypts outside GORM, and the GORM model is a storage struct. +- **GORM:** the generated type is the model. See [`users/gormstore.go`](2026-10-04-plan-builder/users/gormstore.go). -- **sqlc:** column overrides set each column's `go_type`, and `go_struct_tag` puts the `stash` tag on the generated struct. - The sqlc model is a storage struct, and an `INSERT` params struct converts from it. - See [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go) and [`sqlc/sqlc.yaml`](2026-10-04-plan-builder/sqlc/sqlc.yaml). -- **sqlc with EQL domain columns:** sqlc reads a file that declares the domains in place of the EQL install bundle, which it cannot parse. - See [the three rules for EQL domain columns](2026-10-04-plan-builder/README.md#use-sqlc-with-eql-domain-columns). +- **sqlc:** type overrides give each EQL column its Go type, and sqlc's row struct converts to the generated type. + sqlc reads a file that declares the EQL domains in place of the EQL install bundle, which it cannot parse. + See [the three rules for EQL columns](2026-10-04-plan-builder/README.md#use-sqlc-with-eql-columns). + +The SDK gives the values for a search, and the program writes the SQL that uses them. ### Removed -The binding has never been released, so these are removed, not deprecated: +The existing Go package has never been released, so these are removed, not deprecated: -- `Cipher.Encrypt(ctx, v, aad []byte)`: `Cipher.Encrypt` takes a `Context`. -- `Cipher.Decrypt` and `Client.Decrypt`: `DecryptValue[T]` replaces them. +- `Cipher.Encrypt`, `Cipher.Decrypt` and `Client.Decrypt`: an `opaque` struct replaces them. - `EncryptElement` and `DecryptElement`. - `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. - `EncryptedRecord` and `EncryptedField`: a generated type replaces them. -- `Cipher.Term`: the query methods of a generated field entry replace it. -- `RecordOption` and `WithPlan`. -- `TermKind`: `Index` replaces it. +- `Cipher.Term`: the query methods of a field entry replace it. +- `RecordOption`, `WithPlan` and `ExtendContext`: `Cipher.Extend` replaces the last. +- `TermKind`: `Index` replaces it, for generated code. - `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them. -- `PlanFromTags`, and every other function that reads `stash` tags at run time: the generator replaces them. +- `PlanFromTags`, and every other function that reads `stash` tags at run time. - `plan.PlanFor` and `plan.MustPlanFor`: `stashgen.Generate` replaces them. +- `Context`, `NewContext`, `Label`, `NewLabel` and `ParseLabel`: the `context=` tag replaces them. - The rule that a zero `Plan` means the struct's tags. - `Sealed`, `SealedNone`, `SealedEmptyMap` and `SealedEmptySeq` as storage types: `Ciphertext` replaces them. @@ -1071,9 +1119,23 @@ Then: ## Open questions -- **A batch across plans in Go.** - `Encrypt` and `Decrypt` batch the values of one plan. - The Go form of `all(..)` gives a typed handle for each operation, and the Go PR settles its spelling. +- **The Go EQL names.** + The package `eql`, its type names and the values of `encrypt_into` are placeholders. + The EQL typed verb (#1062) settles them. +- **Query building in Go.** + The SDK gives the values for a search, and the program writes the SQL. + A design for building that SQL is separate work. +- **A `go vet` check.** + It reports a struct with sealed fields that a program prints, and an `Encrypt` call inside a loop. + Its design is separate work. +- **A change to a declaration over time.** + This design covers one version of a declaration. + Reading data that an older declaration wrote needs its own design. +- **How the generator gets the engine's rules.** + The generator must refuse what the engine refuses, from one source of rules. + Whether it calls the engine or reads rules the engine publishes is not decided. +- **The policy package.** + Its approach is under review, so "Declarations from a policy" can change. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index f27244c6b..65e33e05c 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -1,46 +1,56 @@ # Go examples for the plan builder -These files show the Go binding from [the plan builder design](../2026-10-04-plan-builder.md#the-go-binding) as complete programs. -The binding does not have this interface yet, so this code does not build in this repository. +These files show the Go SDK from [the plan builder design](../2026-10-04-plan-builder.md#the-go-sdk) as complete programs. +The SDK does not exist yet, so this code does not build in this repository. The module path is `example.com/app`. ## What each file shows | File | What it shows | |---|---| -| [`main.go`](main.go) | A client, one keyset for each tenant, and calls into every package below | -| [`users/model.go`](users/model.go) | A plan in struct tags, and the `go:generate` line that runs `stashgen` with two storage structs | -| [`users/user_stash.go`](users/user_stash.go) | The file that `stashgen` writes: the encrypted type, the plan, the typed fields, and the storage struct conversions | -| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql` with the generated type: insert, batch insert in a transaction, equality search, ORE ordering and JSON containment | -| [`users/gormstore.go`](users/gormstore.go) | GORM, with a hand-written model as a storage struct | -| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with the sqlc model as a storage struct, and an update of one field and its terms | -| [`users/extend.go`](users/extend.go) | A context extension on the write, the query and the read | +| [`main.go`](main.go) | A client, one cipher for each tenant, and a batch of two types in one request | +| [`users/model.go`](users/model.go) | A struct with `stash` tags, and the `go:generate` line | +| [`users/user_stash.go`](users/user_stash.go) | The file `stashgen` writes: the encrypted type, `Encrypt`, `Decrypt` and `Fields` | +| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql`: batch insert in a transaction, and three searches | +| [`users/gormstore.go`](users/gormstore.go) | GORM, with the generated type as the model | +| [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with its row struct converted to the generated type | | [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | -| [`contacts/contacts.go`](contacts/contacts.go) | A plan for [`crm.Contact`](crm/contact.go), a type in another package, with its generated file [`contactstash_stash.go`](contacts/contactstash_stash.go) | +| [`accounts/account.go`](accounts/account.go) | An embedded `gorm.Model`, another library's tags, an unexported field and `-redact` | +| [`contacts/contacts.go`](contacts/contacts.go) | [`crm.Contact`](crm/contact.go), a type in another package, in separate columns, with a model | +| [`documents/documents.go`](documents/documents.go) | An `opaque` struct, sealed as one value | | [`policy/policy.go`](policy/policy.go) | A policy that decides what to encrypt from each field's data categories | | [`cmd/genplans/main.go`](cmd/genplans/main.go) | The generate program that runs the policy | -| [`individuals/`](individuals/) | A type with no tags, and [`individual_stash.go`](individuals/individual_stash.go), the file the policy gives | -| [`individualstore/store.go`](individualstore/store.go) | A store that uses the generated type | -| [`blocklist/blocklist.go`](blocklist/blocklist.go) | One value with no record around it, as a struct with one field, with its generated file [`blocked_stash.go`](blocklist/blocked_stash.go) | -| [`documents/documents.go`](documents/documents.go) | One value sealed as one tree, decrypted with the client | -| [`eql-sqlc/`](eql-sqlc/) | sqlc with EQL v3 domain columns, which generates [`internal/eqldb/`](internal/eqldb/) | +| [`individuals/`](individuals/) | A type with no tags, and the file the policy gives | +| [`individualstore/store.go`](individualstore/store.go) | A store that uses that generated type | -## Generate the encrypted type +## What was checked -`stashgen` does not exist yet, so the four `_stash.go` files are written by hand as the files it will write. +| Claim | Status | +|---|---| +| Every Go file type-checks | Run. `go vet ./...` passes against a stub of the SDK. The stub is not in this repository, and it has signatures only. | +| The generate program builds when a generated file is stale | Run. `go build -tags stashgen ./cmd/genplans` passes after a field is added to `Individual`. | +| A change to a tagged struct, a model or a type in another package stops the build | Run. Each change fails `go build` with "cannot convert". | +| sqlc's row struct converts to the generated type | Run. The conversions in `users/sqlcstore.go` compile against real sqlc output. | +| A struct from another package with an unexported field cannot convert | Run, with `sync.Once`. | +| A generated file from another version does not compile | Run. A file that names an unknown version constant fails `go build`. | +| The files `stashgen` writes | Not run. `stashgen` does not exist, and the five `_stash.go` files are written by hand. | +| The SDK can be built with these signatures | Not run. | +| The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. | +| The EQL types, and how generated code assembles them | Not run. The package name and type names are placeholders. | +| The steps in "Use the SDK" | Not run. Nobody has followed them. | -## Generate the sqlc packages +## Generate the sqlc package -The two sqlc packages were generated with sqlc v1.31.1. -Run `sqlc generate` in `sqlc/` and in `eql-sqlc/` to generate them again. +The sqlc package was generated with sqlc v1.31.1. +Run `sqlc generate` in `sqlc/` to generate it again. -## Use sqlc with EQL domain columns +## Use sqlc with EQL columns -Three rules apply when a column has an EQL v3 domain type: +Three rules apply when a column has an EQL type: - **Give sqlc a file that declares the domains.** sqlc cannot parse the EQL install bundle, because it rejects function overloads that differ only by `text` and `text[]`. - The file [`eql-sqlc/eql-domains.sql`](eql-sqlc/eql-domains.sql) declares each domain as `jsonb`, and the database still gets the real bundle from `stash eql install`. + The file [`sqlc/eql-domains.sql`](sqlc/eql-domains.sql) declares each domain as `jsonb`, and the database still gets the real bundle from `stash eql install`. - **Spell a `db_type` override exactly as the schema spells the type.** `public.eql_v3_text_search` and `eql_v3_text_search` are two different spellings to sqlc. An override with the other spelling matches nothing, and the column becomes `interface{}`. diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account.go b/docs/plans/2026-10-04-plan-builder/accounts/account.go new file mode 100644 index 000000000..412235a74 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/accounts/account.go @@ -0,0 +1,39 @@ +// Package accounts shows an embedded struct, another library's tags, an +// unexported field, and generated print methods. +package accounts + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "gorm.io/gorm" +) + +//go:generate go tool stashgen -type Account -redact + +type Account struct { + _ struct{} `stash:"context=accounts"` + + // One tag decides for every field of gorm.Model, which cannot carry tags. + gorm.Model `stash:",passthrough"` + + // stashgen copies the gorm and json tags onto EncryptedAccount. + Email string `stash:"email,encrypt_into=TextEq" gorm:"uniqueIndex" json:"email"` + + // An unexported field with no stash tag is ignored, and stashgen and the + // running program both warn about it. + cache string + + // The tag acknowledges the skip, so nothing warns. + token string `stash:"-"` +} + +func (EncryptedAccount) TableName() string { return "accounts" } + +func Create(ctx context.Context, db *gorm.DB, cipher *stackencrypt.Cipher, accounts []Account) error { + encrypted, err := Encrypt(ctx, cipher, accounts) + if err != nil { + return err + } + return db.WithContext(ctx).Create(&encrypted).Error +} diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go new file mode 100644 index 000000000..bb05ad194 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -0,0 +1,118 @@ +// Code generated by stashgen. DO NOT EDIT. + +package accounts + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "gorm.io/gorm" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// Not encrypted and not stored: the unexported field "cache". Tag it +// `stash:"-"` to confirm that. + +type EncryptedAccount struct { + gorm.Model + Email eql.TextEq `gorm:"uniqueIndex" json:"email"` +} + +func (e EncryptedAccount) String() string { + return gensupport.Redacted("EncryptedAccount", map[string]any{"Model": e.Model}, "Email") +} + +func (e EncryptedAccount) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"Model": e.Model}, "Email") +} + +// Written because of -redact: Account no longer prints its sealed fields. +func (a Account) String() string { + return gensupport.Redacted("Account", map[string]any{"Model": a.Model}, "Email") +} + +func (a Account) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"Model": a.Model}, "Email") +} + +// Stops compiling when Account gains, loses, reorders or retypes a field. +var _ = accountShape(Account{}) + +type accountShape struct { + _ struct{} + gorm.Model + Email string + cache string + token string +} + +var declaration = gensupport.Declare("accounts"). + Passthrough("id"). + Passthrough("created_at"). + Passthrough("updated_at"). + Passthrough("deleted_at"). + EncryptIndex("email", stackencrypt.Equality). + Omit("token") + +var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ + TypeName: "Account", + Declaration: declaration, + Unexported: []string{"cache"}, + Source: func(v Account) gensupport.Values { + return gensupport.Values{"email": v.Email} + }, + Seal: func(v Account, rec gensupport.Record) EncryptedAccount { + return EncryptedAccount{Model: v.Model, Email: eql.NewTextEq(rec["email"])} + }, + Open: func(e EncryptedAccount) gensupport.Record { + return gensupport.Record{"email": e.Email.Outputs()} + }, + Value: func(e EncryptedAccount, vals gensupport.Values) (Account, error) { + email, err := gensupport.Get[string](vals, "email") + if err != nil { + return Account{}, err + } + return Account{Model: e.Model, Email: email}, nil + }, +}) + +func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, accounts []Account) ([]EncryptedAccount, error) { + return codec.Encrypt(ctx, cipher, accounts) +} + +func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedAccount) ([]Account, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +func Encryption(accounts []Account) stackencrypt.Operation[[]EncryptedAccount] { + return codec.Encryption(accounts) +} + +func Decryption(encrypted []EncryptedAccount) stackencrypt.Operation[[]Account] { + return codec.Decryption(encrypted) +} + +var Fields = struct { + Email EmailField +}{ + Email: EmailField{gensupport.NewField[string](declaration, "email")}, +} + +type EmailField struct { + field gensupport.Field[string] +} + +func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewTextEq(out), err +} + +func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.NewTextEqQuery(out), err +} diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go index 7a521c27c..aaecd54d9 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -1,4 +1,5 @@ -// Package contacts encrypts crm.Contact, a type that cannot carry stash tags. +// Package contacts encrypts crm.Contact, a type from another package, into +// separate columns: one for the ciphertext and one for each term. package contacts import ( @@ -7,13 +8,14 @@ import ( "example.com/app/crm" "github.com/cipherstash/stack/languages/golang/stackencrypt" + "gorm.io/gorm" ) -//go:generate go tool stashgen -type contactStash -for crm.Contact +//go:generate go tool stashgen -type contactStash -for crm.Contact -record Rows=ContactRow -// contactStash declares the plan for crm.Contact. stashgen matches each field -// to the crm.Contact field with the same name and type, and refuses a -// crm.Contact field this struct does not name. +// contactStash declares the tags for crm.Contact, which cannot carry them. +// stashgen matches each field to the crm.Contact field with the same name and +// type, and refuses a crm.Contact field this struct does not name. type contactStash struct { _ struct{} `stash:"context=contacts"` ID int64 `stash:"id,passthrough"` @@ -22,22 +24,49 @@ type contactStash struct { Internal string `stash:"-"` } -func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, contact crm.Contact) error { - encrypted, err := ContactPlan.Encrypt(ctx, cipher, []crm.Contact{contact}) +// ContactRow is a model: one field for each column. Each tag names the output +// the field holds. +type ContactRow struct { + ID int64 `stash:"id"` + Email stackencrypt.Ciphertext `stash:"email"` + EmailEq stackencrypt.EqualityTerm `stash:"email,equality"` + EmailMatch stackencrypt.MatchTerm `stash:"email,match"` + PhoneNumber stackencrypt.Ciphertext `stash:"phone_number"` + PhoneNumberEq stackencrypt.EqualityTerm `stash:"phone_number,equality"` +} + +func (ContactRow) TableName() string { return "contacts" } + +// Create writes with database/sql and passes each output itself. +func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, list []crm.Contact) error { + encrypted, err := Encrypt(ctx, cipher, list) + if err != nil { + return err + } + for _, e := range encrypted { + _, err := db.ExecContext(ctx, ` + INSERT INTO contacts (id, email, email_eq, email_match, phone_number, phone_number_eq) + VALUES ($1, $2, $3, $4, $5, $6)`, + e.ID, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, + e.PhoneNumber.Ciphertext, e.PhoneNumber.Equality) + if err != nil { + return err + } + } + return nil +} + +// CreateWithGORM writes the model, which GORM maps one field to one column. +func CreateWithGORM(ctx context.Context, db *gorm.DB, cipher *stackencrypt.Cipher, list []crm.Contact) error { + rows, err := EncryptRows(ctx, cipher, list) if err != nil { return err } - enc := encrypted[0] - _, err = db.ExecContext(ctx, ` - INSERT INTO contacts (id, email, email_eq, email_match, phone_number, phone_number_eq) - VALUES ($1, $2, $3, $4, $5, $6)`, - enc.ID, enc.Email.Ciphertext, enc.Email.Equality, enc.Email.Match, - enc.PhoneNumber.Ciphertext, enc.PhoneNumber.Equality) - return err + return db.WithContext(ctx).Create(&rows).Error } func IDByPhone(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, phone string) (int64, error) { - term, err := ContactFields.PhoneNumber.Equality(ctx, cipher, phone) + term, err := Fields.PhoneNumber.Equality(ctx, cipher, phone) if err != nil { return 0, err } diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go new file mode 100644 index 000000000..14585b9a6 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -0,0 +1,193 @@ +// Code generated by stashgen. DO NOT EDIT. + +package contacts + +import ( + "context" + "log/slog" + + "example.com/app/crm" + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// crm.Contact prints its sealed fields in the clear, and stashgen cannot add +// print methods to a type from another package. + +type EncryptedContact struct { + ID int64 + Email EncryptedContactEmail + PhoneNumber EncryptedContactPhoneNumber +} + +type EncryptedContactEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Match stackencrypt.MatchTerm +} + +type EncryptedContactPhoneNumber struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm +} + +func (e EncryptedContact) String() string { + return gensupport.Redacted("EncryptedContact", map[string]any{"ID": e.ID}, "Email", "PhoneNumber") +} + +func (e EncryptedContact) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "PhoneNumber") +} + +// Stops compiling when crm.Contact gains, loses, reorders or retypes a field. +var _ = contactShape(crm.Contact{}) + +type contactShape struct { + ID int64 + Email string + PhoneNumber string + Internal string +} + +var declaration = gensupport.Declare("contacts"). + Passthrough("id"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("phone_number", stackencrypt.Equality). + Omit("internal") + +var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ + TypeName: "crm.Contact", + Declaration: declaration, + PrintsPlaintext: true, + Source: func(v crm.Contact) gensupport.Values { + return gensupport.Values{"email": v.Email, "phone_number": v.PhoneNumber} + }, + Seal: func(v crm.Contact, rec gensupport.Record) EncryptedContact { + email, phone := rec["email"], rec["phone_number"] + return EncryptedContact{ + ID: v.ID, + Email: EncryptedContactEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, + PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, + } + }, + Open: func(e EncryptedContact) gensupport.Record { + return gensupport.Record{ + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, + } + }, + Value: func(e EncryptedContact, vals gensupport.Values) (crm.Contact, error) { + v := crm.Contact{ID: e.ID} + var err error + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return crm.Contact{}, err + } + if v.PhoneNumber, err = gensupport.Get[string](vals, "phone_number"); err != nil { + return crm.Contact{}, err + } + return v, nil + }, +}) + +func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, contacts []crm.Contact) ([]EncryptedContact, error) { + return codec.Encrypt(ctx, cipher, contacts) +} + +func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedContact) ([]crm.Contact, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +func Encryption(contacts []crm.Contact) stackencrypt.Operation[[]EncryptedContact] { + return codec.Encryption(contacts) +} + +func Decryption(encrypted []EncryptedContact) stackencrypt.Operation[[]crm.Contact] { + return codec.Decryption(encrypted) +} + +var Fields = struct { + Email EmailField + PhoneNumber PhoneNumberField +}{ + Email: EmailField{gensupport.NewField[string](declaration, "email")}, + PhoneNumber: PhoneNumberField{gensupport.NewField[string](declaration, "phone_number")}, +} + +type EmailField struct { + field gensupport.Field[string] +} + +func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedContactEmail, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedContactEmail{}, err + } + return EncryptedContactEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil +} + +func (f EmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { + return f.field.Equality(ctx, c, v) +} + +func (f EmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.MatchTerm, error) { + return f.field.Match(ctx, c, v) +} + +type PhoneNumberField struct { + field gensupport.Field[string] +} + +func (f PhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedContactPhoneNumber, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedContactPhoneNumber{}, err + } + return EncryptedContactPhoneNumber{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f PhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { + return f.field.Equality(ctx, c, v) +} + +// The -record flag: ContactRow converts to and from its shape only while the +// two have the same fields, with the same types, in the same order. +type rowsShape struct { + ID int64 + Email stackencrypt.Ciphertext + EmailEq stackencrypt.EqualityTerm + EmailMatch stackencrypt.MatchTerm + PhoneNumber stackencrypt.Ciphertext + PhoneNumberEq stackencrypt.EqualityTerm +} + +var rowsCodec = gensupport.Records(codec, + func(e EncryptedContact) ContactRow { + return ContactRow(rowsShape{ + ID: e.ID, + Email: e.Email.Ciphertext, + EmailEq: e.Email.Equality, + EmailMatch: e.Email.Match, + PhoneNumber: e.PhoneNumber.Ciphertext, + PhoneNumberEq: e.PhoneNumber.Equality, + }) + }, + func(r ContactRow) EncryptedContact { + s := rowsShape(r) + return EncryptedContact{ + ID: s.ID, + Email: EncryptedContactEmail{Ciphertext: s.Email, Equality: s.EmailEq, Match: s.EmailMatch}, + PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: s.PhoneNumber, Equality: s.PhoneNumberEq}, + } + }, +) + +func EncryptRows(ctx context.Context, cipher *stackencrypt.Cipher, contacts []crm.Contact) ([]ContactRow, error) { + return rowsCodec.Encrypt(ctx, cipher, contacts) +} + +func DecryptRows(ctx context.Context, d stackencrypt.Decrypter, rows []ContactRow) ([]crm.Contact, error) { + return rowsCodec.Decrypt(ctx, d, rows) +} diff --git a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go new file mode 100644 index 000000000..aa7641e03 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go @@ -0,0 +1,89 @@ +// Code generated by stashgen. DO NOT EDIT. + +package documents + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// Document prints its sealed fields in the clear: it has no String or +// LogValue method. Write them, or run stashgen with -redact. + +type EncryptedDocument struct { + Sealed stackencrypt.Ciphertext +} + +func (e EncryptedDocument) String() string { + return gensupport.Redacted("EncryptedDocument", nil, "Sealed") +} + +func (e EncryptedDocument) LogValue() slog.Value { + return gensupport.RedactedLog(nil, "Sealed") +} + +// Stops compiling when Document gains, loses, reorders or retypes a field. +var _ = documentShape(Document{}) + +type documentShape struct { + _ struct{} + Title string + Body string + Tags []string +} + +var declaration = gensupport.DeclareOpaque("documents/v2/body") + +var codec = gensupport.New(gensupport.Generated[Document, EncryptedDocument]{ + TypeName: "Document", + Declaration: declaration, + PrintsPlaintext: true, + Source: func(v Document) gensupport.Values { + return gensupport.Values{gensupport.OpaqueField: map[string]any{"title": v.Title, "body": v.Body, "tags": v.Tags}} + }, + Seal: func(v Document, rec gensupport.Record) EncryptedDocument { + return EncryptedDocument{Sealed: rec[gensupport.OpaqueField].Ciphertext} + }, + Open: func(e EncryptedDocument) gensupport.Record { + return gensupport.Record{gensupport.OpaqueField: {Ciphertext: e.Sealed}} + }, + Value: func(e EncryptedDocument, vals gensupport.Values) (Document, error) { + fields, err := gensupport.Get[gensupport.Values](vals, gensupport.OpaqueField) + if err != nil { + return Document{}, err + } + var v Document + if v.Title, err = gensupport.Get[string](fields, "title"); err != nil { + return Document{}, err + } + if v.Body, err = gensupport.Get[string](fields, "body"); err != nil { + return Document{}, err + } + if v.Tags, err = gensupport.Get[[]string](fields, "tags"); err != nil { + return Document{}, err + } + return v, nil + }, +}) + +func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, documents []Document) ([]EncryptedDocument, error) { + return codec.Encrypt(ctx, cipher, documents) +} + +func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedDocument) ([]Document, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +func Encryption(documents []Document) stackencrypt.Operation[[]EncryptedDocument] { + return codec.Encryption(documents) +} + +func Decryption(encrypted []EncryptedDocument) stackencrypt.Operation[[]Document] { + return codec.Decryption(encrypted) +} diff --git a/docs/plans/2026-10-04-plan-builder/documents/documents.go b/docs/plans/2026-10-04-plan-builder/documents/documents.go index 4a78e98c7..8065d0d1e 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/documents.go +++ b/docs/plans/2026-10-04-plan-builder/documents/documents.go @@ -1,5 +1,5 @@ -// Package documents seals each document as one tree under one context. A -// document is stored and read whole, so it needs no plan. +// Package documents seals each document as one value. Nothing inside it can +// be read or searched on its own, so its fields carry no tags. package documents import ( @@ -9,44 +9,35 @@ import ( "github.com/cipherstash/stack/languages/golang/stackencrypt" ) +//go:generate go tool stashgen -type Document + type Document struct { + _ struct{} `stash:"context=documents/v2/body,opaque"` Title string Body string Tags []string } -func bodyContext() (stackencrypt.Context, error) { - label, err := stackencrypt.NewLabel("documents", "v2", "body") - if err != nil { - return stackencrypt.Context{}, err - } - return label.Context(), nil -} - func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64, doc Document) error { - body, err := bodyContext() + encrypted, err := Encrypt(ctx, cipher, []Document{doc}) if err != nil { return err } - sealed, err := cipher.Encrypt(ctx, doc, body) - if err != nil { - return err - } - _, err = db.ExecContext(ctx, `INSERT INTO documents (id, body) VALUES ($1, $2)`, id, sealed) + _, err = db.ExecContext(ctx, `INSERT INTO documents (id, body) VALUES ($1, $2)`, id, encrypted[0].Sealed) return err } -// Load decrypts with the client, which opens each leaf under the keyset that -// sealed it. A *stackencrypt.Cipher would also refuse a leaf from another +// Load decrypts with the client, which opens each value under the keyset that +// sealed it. A *stackencrypt.Cipher would also refuse a value from another // keyset. func Load(ctx context.Context, db *sql.DB, client *stackencrypt.Client, id int64) (Document, error) { - body, err := bodyContext() - if err != nil { + var encrypted EncryptedDocument + if err := db.QueryRowContext(ctx, `SELECT body FROM documents WHERE id = $1`, id).Scan(&encrypted.Sealed); err != nil { return Document{}, err } - var sealed stackencrypt.Ciphertext - if err := db.QueryRowContext(ctx, `SELECT body FROM documents WHERE id = $1`, id).Scan(&sealed); err != nil { + docs, err := Decrypt(ctx, client, []EncryptedDocument{encrypted}) + if err != nil { return Document{}, err } - return stackencrypt.DecryptValue[Document](ctx, client, sealed, body) + return docs[0], nil } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go new file mode 100644 index 000000000..c392a147d --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -0,0 +1,146 @@ +// Code generated by stashgen. DO NOT EDIT. + +//go:build !stashgen + +package individuals + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// Individual prints its sealed fields in the clear: it has no String or +// LogValue method. Write them, or run stashgen with -redact. + +type EncryptedIndividual struct { + ID int64 + Name EncryptedIndividualName + Email EncryptedIndividualEmail + MedicareNo EncryptedIndividualMedicareNo + Nickname string +} + +type EncryptedIndividualName struct { + Ciphertext stackencrypt.Ciphertext +} + +type EncryptedIndividualEmail struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm + Match stackencrypt.MatchTerm +} + +type EncryptedIndividualMedicareNo struct { + Ciphertext stackencrypt.Ciphertext + Equality stackencrypt.EqualityTerm +} + +func (e EncryptedIndividual) String() string { + return gensupport.Redacted("EncryptedIndividual", map[string]any{"ID": e.ID, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") +} + +func (e EncryptedIndividual) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") +} + +// Stops compiling when Individual gains, loses, reorders or retypes a field. +var _ = individualShape(Individual{}) + +type individualShape struct { + ID int64 + Name string + Email string + MedicareNo string + Nickname string +} + +var declaration = gensupport.Declare("individuals"). + Passthrough("id"). + Encrypt("name"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). + EncryptIndex("medicare_number", stackencrypt.Equality). + Passthrough("nickname") + +var codec = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual]{ + TypeName: "Individual", + Declaration: declaration, + PrintsPlaintext: true, + Source: func(v Individual) gensupport.Values { + return gensupport.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} + }, + Seal: func(v Individual, rec gensupport.Record) EncryptedIndividual { + email, medicare := rec["email"], rec["medicare_number"] + return EncryptedIndividual{ + ID: v.ID, + Name: EncryptedIndividualName{Ciphertext: rec["name"].Ciphertext}, + Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, + MedicareNo: EncryptedIndividualMedicareNo{Ciphertext: medicare.Ciphertext, Equality: medicare.Equality}, + Nickname: v.Nickname, + } + }, + Open: func(e EncryptedIndividual) gensupport.Record { + return gensupport.Record{ + "name": {Ciphertext: e.Name.Ciphertext}, + "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, + "medicare_number": {Ciphertext: e.MedicareNo.Ciphertext, Equality: e.MedicareNo.Equality}, + } + }, + Value: func(e EncryptedIndividual, vals gensupport.Values) (Individual, error) { + v := Individual{ID: e.ID, Nickname: e.Nickname} + var err error + if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { + return Individual{}, err + } + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return Individual{}, err + } + if v.MedicareNo, err = gensupport.Get[string](vals, "medicare_number"); err != nil { + return Individual{}, err + } + return v, nil + }, +}) + +func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, individuals []Individual) ([]EncryptedIndividual, error) { + return codec.Encrypt(ctx, cipher, individuals) +} + +func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedIndividual) ([]Individual, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +func Encryption(individuals []Individual) stackencrypt.Operation[[]EncryptedIndividual] { + return codec.Encryption(individuals) +} + +func Decryption(encrypted []EncryptedIndividual) stackencrypt.Operation[[]Individual] { + return codec.Decryption(encrypted) +} + +var Fields = struct { + MedicareNo MedicareNoField +}{ + MedicareNo: MedicareNoField{gensupport.NewField[string](declaration, "medicare_number")}, +} + +type MedicareNoField struct { + field gensupport.Field[string] +} + +func (f MedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedIndividualMedicareNo, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedIndividualMedicareNo{}, err + } + return EncryptedIndividualMedicareNo{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f MedicareNoField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { + return f.field.Equality(ctx, c, v) +} diff --git a/docs/plans/2026-10-04-plan-builder/individualstore/store.go b/docs/plans/2026-10-04-plan-builder/individualstore/store.go index 5e0f950f2..0f2665d66 100644 --- a/docs/plans/2026-10-04-plan-builder/individualstore/store.go +++ b/docs/plans/2026-10-04-plan-builder/individualstore/store.go @@ -10,21 +10,21 @@ import ( ) func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person individuals.Individual) error { - encrypted, err := stackencrypt.Encrypt(ctx, cipher, []individuals.Individual{person}) + encrypted, err := individuals.Encrypt(ctx, cipher, []individuals.Individual{person}) if err != nil { return err } - enc := encrypted[0] + e := encrypted[0] _, err = db.ExecContext(ctx, ` INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - enc.ID, enc.Nickname, enc.Name, enc.Email.Ciphertext, enc.Email.Equality, enc.Email.Match, - enc.MedicareNo.Ciphertext, enc.MedicareNo.Equality) + e.ID, e.Nickname, e.Name.Ciphertext, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, + e.MedicareNo.Ciphertext, e.MedicareNo.Equality) return err } func IDByMedicare(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, medicareNo string) (int64, error) { - term, err := individuals.IndividualFields.MedicareNo.Equality(ctx, cipher, medicareNo) + term, err := individuals.Fields.MedicareNo.Equality(ctx, cipher, medicareNo) if err != nil { return 0, err } diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go new file mode 100644 index 000000000..7e793aebb --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package userdb + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go new file mode 100644 index 000000000..ddea2ddb0 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package userdb + +import ( + "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" +) + +type User struct { + ID int64 + Email eql.TextSearch + Age eql.IntegerOrd + Attrs eql.JSON + Notes eql.Text +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go new file mode 100644 index 000000000..a5bf23b51 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go @@ -0,0 +1,153 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package userdb + +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" +) + +const createUser = `-- name: CreateUser :exec +INSERT INTO users (id, email, age, attrs, notes) VALUES ($1, $2, $3, $4, $5) +` + +type CreateUserParams struct { + ID int64 + Email eql.TextSearch + Age eql.IntegerOrd + Attrs eql.JSON + Notes eql.Text +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { + _, err := q.db.ExecContext(ctx, createUser, + arg.ID, + arg.Email, + arg.Age, + arg.Attrs, + arg.Notes, + ) + return err +} + +const findUsersByEmail = `-- name: FindUsersByEmail :many +SELECT id, email, age, attrs, notes FROM users WHERE email = $1::eql_v3.query_text_search +` + +// One cast, straight to the query domain: sqlc types a parameter by its first +// cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. +func (q *Queries) FindUsersByEmail(ctx context.Context, email eql.TextSearchQuery) ([]User, error) { + rows, err := q.db.QueryContext(ctx, findUsersByEmail, email) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + &i.Notes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUser = `-- name: GetUser :one +SELECT id, email, age, attrs, notes FROM users WHERE id = $1 +` + +func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRowContext(ctx, getUser, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + &i.Notes, + ) + return i, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, email, age, attrs, notes FROM users ORDER BY id +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + &i.Notes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersAtLeast = `-- name: ListUsersAtLeast :many +SELECT id, email, age, attrs, notes FROM users WHERE age >= $1::eql_v3.query_integer_ord ORDER BY age +` + +func (q *Queries) ListUsersAtLeast(ctx context.Context, minAge eql.IntegerOrdQuery) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsersAtLeast, minAge) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Age, + &i.Attrs, + &i.Notes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go index 7c54627be..4e8c7cc2a 100644 --- a/docs/plans/2026-10-04-plan-builder/main.go +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -9,7 +9,8 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" - "example.com/app/blocklist" + "example.com/app/contacts" + "example.com/app/crm" "example.com/app/documents" "example.com/app/users" "github.com/cipherstash/stack/languages/golang/stackencrypt" @@ -34,9 +35,10 @@ func run(ctx context.Context) error { } defer db.Close() - const tenant = "tenant-42" - cipher := client.Keyset(stackencrypt.KeysetName(tenant)) - store := users.NewSQLStore(db, client) + // One cipher for each tenant: its keyset, and its part of every field's + // context. Every call through this cipher carries both. + cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")).Extend("tenant-42") + store := users.NewSQLStore(db) alice := users.User{ ID: 1, @@ -51,34 +53,30 @@ func run(ctx context.Context) error { {ID: 3, Email: "carol@example.com", Age: 52, Attrs: map[string]any{"role": "admin"}}, } - if err := store.Create(ctx, tenant, alice); err != nil { + if err := store.Create(ctx, cipher, alice); err != nil { return err } - if err := store.Import(ctx, tenant, newHires); err != nil { + if err := store.Import(ctx, cipher, newHires); err != nil { return err } - bobs, err := store.FindByEmail(ctx, tenant, "bob@example.com") + bobs, err := store.FindByEmail(ctx, cipher, "bob@example.com") if err != nil { return err } - admins, err := store.WithRole(ctx, tenant, "admin") + admins, err := store.WithRole(ctx, cipher, "admin") if err != nil { return err } - adults, err := store.OldestFirst(ctx, tenant, 18) + adults, err := store.AtLeast(ctx, cipher, 18) if err != nil { return err } - if _, err := users.RoundTripForTenant(ctx, cipher, "tenant-42-region-ap", alice); err != nil { - return err - } - blocked := blocklist.New(db, cipher) - if err := blocked.Block(ctx, "spam@example.net"); err != nil { - return err - } - isBlocked, err := blocked.Blocked(ctx, "spam@example.net") + // Two types in one ZeroKMS request. + list := []crm.Contact{{ID: 9, Email: "dan@example.com", PhoneNumber: "+61 400 000 000"}} + encryptedUsers, encryptedContacts, err := stackencrypt.Batch2(ctx, cipher, + users.Encryption(newHires), contacts.Encryption(list)) if err != nil { return err } @@ -92,7 +90,8 @@ func run(ctx context.Context) error { } // Print ids and counts only. Every other value here is plaintext. - fmt.Println("bob:", ids(bobs), "admins:", ids(admins), "adults, oldest first:", ids(adults), "blocked:", isBlocked) + fmt.Println("bob:", ids(bobs), "admins:", ids(admins), "adults:", ids(adults), + "batched:", len(encryptedUsers), len(encryptedContacts)) return nil } diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql b/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql index 1b93ae49e..5203b5f40 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql @@ -5,6 +5,7 @@ CREATE SCHEMA eql_v3; CREATE DOMAIN public.eql_v3_text_search AS jsonb; CREATE DOMAIN public.eql_v3_integer_ord AS jsonb; CREATE DOMAIN public.eql_v3_json_search AS jsonb; +CREATE DOMAIN public.eql_v3_text AS jsonb; CREATE DOMAIN eql_v3.query_text_search AS jsonb; CREATE DOMAIN eql_v3.query_integer_ord AS jsonb; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql index ccc33ca35..f29d5f87a 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql @@ -1,13 +1,16 @@ -- name: CreateUser :exec -INSERT INTO users (id, email, age, attrs) VALUES ($1, $2, $3, $4); +INSERT INTO users (id, email, age, attrs, notes) VALUES ($1, $2, $3, $4, $5); + +-- name: GetUser :one +SELECT * FROM users WHERE id = $1; + +-- name: ListUsers :many +SELECT * FROM users ORDER BY id; -- One cast, straight to the query domain: sqlc types a parameter by its first -- cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. -- name: FindUsersByEmail :many SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_search; --- name: SearchUsersByEmail :many -SELECT * FROM users WHERE email @@ sqlc.arg(pattern)::eql_v3.query_text_search; - --- name: ListUsersOlderThan :many -SELECT * FROM users WHERE age > sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; +-- name: ListUsersAtLeast :many +SELECT * FROM users WHERE age >= sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql index 0a78502cd..3e7d1a606 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql @@ -2,5 +2,6 @@ CREATE TABLE users ( id bigint PRIMARY KEY, email public.eql_v3_text_search NOT NULL, age public.eql_v3_integer_ord NOT NULL, - attrs public.eql_v3_json_search NOT NULL + attrs public.eql_v3_json_search NOT NULL, + notes public.eql_v3_text NOT NULL ); diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml index 5b9cbaf44..82f23b053 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml +++ b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml @@ -7,17 +7,18 @@ sql: queries: query.sql gen: go: - package: eqldb - out: ../internal/eqldb - emit_db_tags: true + package: userdb + out: ../internal/userdb overrides: - db_type: "public.eql_v3_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: TextSearch } + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextSearch } - db_type: "public.eql_v3_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: IntegerOrd } + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: IntegerOrd } - db_type: "public.eql_v3_json_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: JSONSearch } + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: JSON } + - db_type: "public.eql_v3_text" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: Text } - db_type: "eql_v3.query_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryTextSearch } + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextSearchQuery } - db_type: "eql_v3.query_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eqlv3, type: QueryIntegerOrd } + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: IntegerOrdQuery } diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index d6f44e200..10f375bbb 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -7,54 +7,38 @@ import ( "gorm.io/gorm" ) -// UserRow is the GORM model: one field for each column. GORM's default naming -// maps EmailEq to email_eq. stashgen reads its stash tags and writes UserRows, -// which stops compiling when this struct changes. -type UserRow struct { - ID int64 `stash:"id"` - Email stackencrypt.Ciphertext `stash:"email"` - EmailEq stackencrypt.EqualityTerm `stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `stash:"email,match"` - Age stackencrypt.Ciphertext `stash:"age"` - AgeEq stackencrypt.EqualityTerm `stash:"age,equality"` - AgeOre stackencrypt.OreTerm `stash:"age,ore"` - Attrs stackencrypt.JSONDocument `stash:"attrs,json"` - Notes stackencrypt.Ciphertext `stash:"notes"` -} - -func (UserRow) TableName() string { return "users" } +// TableName lets GORM use the generated type as the model. Without it GORM +// would look for a table named encrypted_users. +func (EncryptedUser) TableName() string { return "users" } // GormStore encrypts before GORM sees a value. driver.Valuer gets no // context.Context and runs one field at a time, so it cannot batch a ZeroKMS -// request or stop when the request is cancelled. An AfterFind hook works too, -// but it decrypts one row per ZeroKMS request. +// request or stop when the request is cancelled. type GormStore struct { - db *gorm.DB - client *stackencrypt.Client + db *gorm.DB } -func NewGormStore(db *gorm.DB, client *stackencrypt.Client) *GormStore { - return &GormStore{db: db, client: client} +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} } -func (s *GormStore) Create(ctx context.Context, tenant string, people ...User) error { - cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - rows, err := UserRows.Encrypt(ctx, cipher, people) +func (s *GormStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, people ...User) error { + encrypted, err := Encrypt(ctx, cipher, people) if err != nil { return err } - return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error + return s.db.WithContext(ctx).CreateInBatches(encrypted, 500).Error } -func (s *GormStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { - cipher := s.client.Keyset(stackencrypt.KeysetName(tenant)) - term, err := UserFields.Email.Equality(ctx, cipher, email) +func (s *GormStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { + query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err } - var rows []UserRow - if err := s.db.WithContext(ctx).Where("email_eq = ?", term).Find(&rows).Error; err != nil { + var encrypted []EncryptedUser + err = s.db.WithContext(ctx).Where("email = ?::eql_v3.query_text_search", query).Find(&encrypted).Error + if err != nil { return nil, err } - return UserRows.Decrypt(ctx, cipher, rows) + return Decrypt(ctx, cipher, encrypted) } diff --git a/docs/plans/2026-10-04-plan-builder/users/model.go b/docs/plans/2026-10-04-plan-builder/users/model.go index 1deb279f5..4ed7d8f92 100644 --- a/docs/plans/2026-10-04-plan-builder/users/model.go +++ b/docs/plans/2026-10-04-plan-builder/users/model.go @@ -1,15 +1,15 @@ package users -//go:generate go tool stashgen -type User -row UserRows=UserRow -row SQLCUsers=userdb.User +//go:generate go tool stashgen -type User -// User's tags are the plan; stashgen writes user_stash.go from them. An -// exported field with no stash tag is refused, never stored unencrypted. +// Every exported field needs a stash tag, so a new field cannot reach the +// database unencrypted by accident. type User struct { _ struct{} `stash:"context=users"` - ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt,index=equality;match"` - Age uint32 `stash:"age,encrypt,index=equality;ore"` - Attrs map[string]any `stash:"attrs,index=json"` - Notes string `stash:"notes,encrypt"` + ID int64 `stash:"id,passthrough" db:"id" gorm:"primaryKey"` + Email string `stash:"email,encrypt_into=TextSearch" db:"email"` + Age int32 `stash:"age,encrypt_into=IntegerOrd" db:"age"` + Attrs map[string]any `stash:"attrs,encrypt_into=JSON" db:"attrs"` + Notes string `stash:"notes,encrypt_into=Text" db:"notes"` Internal string `stash:"-"` } diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index 2b8cc03e5..0fbd06e73 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "fmt" "example.com/app/internal/userdb" "github.com/cipherstash/stack/languages/golang/stackencrypt" @@ -12,38 +11,23 @@ import ( var ErrNotFound = errors.New("users: not found") -// SQLCStore keeps users in Postgres through the queries sqlc generates. +// SQLCStore keeps users in Postgres through the queries sqlc generates. sqlc +// writes its own row struct, userdb.User. It has the same fields as +// EncryptedUser, so Go converts one to the other, and a change to either +// stops this file compiling. type SQLCStore struct { db *sql.DB queries *userdb.Queries - client *stackencrypt.Client } -func NewSQLCStore(db *sql.DB, client *stackencrypt.Client) *SQLCStore { - return &SQLCStore{db: db, queries: userdb.New(db), client: client} +func NewSQLCStore(db *sql.DB) *SQLCStore { + return &SQLCStore{db: db, queries: userdb.New(db)} } -func (s *SQLCStore) cipher(tenant string) *stackencrypt.Cipher { - return s.client.Keyset(stackencrypt.KeysetName(tenant)) -} - -func (s *SQLCStore) Create(ctx context.Context, tenant string, user User) error { - rows, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), []User{user}) - if err != nil { - return fmt.Errorf("encrypt user %d: %w", user.ID, err) - } - // CreateUserParams has the same fields as userdb.User, in the same order, - // so Go converts one to the other. If a change to the query breaks that, - // this line stops compiling. - return s.queries.CreateUser(ctx, userdb.CreateUserParams(rows[0])) -} - -// Import encrypts every user in one ZeroKMS request, then inserts them in one -// transaction. -func (s *SQLCStore) Import(ctx context.Context, tenant string, people []User) error { - rows, err := SQLCUsers.Encrypt(ctx, s.cipher(tenant), people) +func (s *SQLCStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, people []User) error { + encrypted, err := Encrypt(ctx, cipher, people) if err != nil { - return fmt.Errorf("encrypt %d users: %w", len(people), err) + return err } tx, err := s.db.BeginTx(ctx, nil) @@ -52,16 +36,16 @@ func (s *SQLCStore) Import(ctx context.Context, tenant string, people []User) er } defer tx.Rollback() - qtx := s.queries.WithTx(tx) - for _, row := range rows { - if err := qtx.CreateUser(ctx, userdb.CreateUserParams(row)); err != nil { - return fmt.Errorf("insert user %d: %w", row.ID, err) + queries := s.queries.WithTx(tx) + for _, e := range encrypted { + if err := queries.CreateUser(ctx, userdb.CreateUserParams(e)); err != nil { + return err } } return tx.Commit() } -func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, error) { +func (s *SQLCStore) Get(ctx context.Context, cipher *stackencrypt.Cipher, id int64) (User, error) { row, err := s.queries.GetUser(ctx, id) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrNotFound @@ -69,46 +53,40 @@ func (s *SQLCStore) Get(ctx context.Context, tenant string, id int64) (User, err if err != nil { return User{}, err } - users, err := SQLCUsers.Decrypt(ctx, s.cipher(tenant), []userdb.User{row}) + users, err := Decrypt(ctx, cipher, []EncryptedUser{EncryptedUser(row)}) if err != nil { return User{}, err } return users[0], nil } -func (s *SQLCStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { - cipher := s.cipher(tenant) - term, err := UserFields.Email.Equality(ctx, cipher, email) +func (s *SQLCStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { + query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err } - rows, err := s.queries.FindUsersByEmail(ctx, term) + rows, err := s.queries.FindUsersByEmail(ctx, query) if err != nil { return nil, err } - return SQLCUsers.Decrypt(ctx, cipher, rows) + return Decrypt(ctx, cipher, fromRows(rows)) } -func (s *SQLCStore) List(ctx context.Context, tenant string) ([]User, error) { - rows, err := s.queries.ListUsers(ctx) +// ChangeEmail rewrites one field. One EQL column holds the ciphertext and +// every term, so they cannot go out of step. +func (s *SQLCStore) ChangeEmail(ctx context.Context, cipher *stackencrypt.Cipher, id int64, email string) error { + sealed, err := Fields.Email.Encrypt(ctx, cipher, email) if err != nil { - return nil, err + return err } - return SQLCUsers.Decrypt(ctx, s.cipher(tenant), rows) + _, err = s.db.ExecContext(ctx, `UPDATE users SET email = $2 WHERE id = $1`, id, sealed) + return err } -// ChangeEmail rewrites one field. The email field owns three columns, and all -// three change together: a stale email_eq would let FindByEmail match the old -// address. -func (s *SQLCStore) ChangeEmail(ctx context.Context, tenant string, id int64, email string) error { - field, err := UserFields.Email.Encrypt(ctx, s.cipher(tenant), email) - if err != nil { - return err +func fromRows(rows []userdb.User) []EncryptedUser { + encrypted := make([]EncryptedUser, len(rows)) + for i, row := range rows { + encrypted[i] = EncryptedUser(row) } - return s.queries.UpdateUserEmail(ctx, userdb.UpdateUserEmailParams{ - ID: id, - Email: field.Ciphertext, - EmailEq: field.Equality, - EmailMatch: field.Match, - }) + return encrypted } diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go index cbceadf62..d1ab0a6e1 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -4,45 +4,30 @@ import ( "context" "database/sql" "fmt" - "slices" "github.com/cipherstash/stack/languages/golang/stackencrypt" ) const ( - columns = `id, email, email_eq, email_match, age, age_eq, age_ore, attrs, notes` - insertUser = `INSERT INTO users (` + columns + `) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)` + columns = `id, email, age, attrs, notes` + insertUser = `INSERT INTO users (` + columns + `) VALUES ($1, $2, $3, $4, $5)` selectUser = `SELECT ` + columns + ` FROM users` ) -// SQLStore keeps users in Postgres through database/sql. Each tenant has its -// own keyset; every tenant shares the plan. +// SQLStore keeps users in Postgres through database/sql. Each field is one +// EQL column, so the generated type's fields go straight to Exec and Scan. type SQLStore struct { - db *sql.DB - client *stackencrypt.Client + db *sql.DB } -func NewSQLStore(db *sql.DB, client *stackencrypt.Client) *SQLStore { - return &SQLStore{db: db, client: client} -} - -func (s *SQLStore) cipher(tenant string) *stackencrypt.Cipher { - return s.client.Keyset(stackencrypt.KeysetName(tenant)) -} - -func (s *SQLStore) Create(ctx context.Context, tenant string, user User) error { - encrypted, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), []User{user}) - if err != nil { - return fmt.Errorf("encrypt user %d: %w", user.ID, err) - } - _, err = s.db.ExecContext(ctx, insertUser, args(encrypted[0])...) - return err +func NewSQLStore(db *sql.DB) *SQLStore { + return &SQLStore{db: db} } // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. -func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) error { - encrypted, err := stackencrypt.Encrypt(ctx, s.cipher(tenant), people) +func (s *SQLStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, people []User) error { + encrypted, err := Encrypt(ctx, cipher, people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) } @@ -59,56 +44,54 @@ func (s *SQLStore) Import(ctx context.Context, tenant string, people []User) err } defer stmt.Close() - for _, enc := range encrypted { - if _, err := stmt.ExecContext(ctx, args(enc)...); err != nil { - return fmt.Errorf("insert user %d: %w", enc.ID, err) + for _, e := range encrypted { + if _, err := stmt.ExecContext(ctx, e.ID, e.Email, e.Age, e.Attrs, e.Notes); err != nil { + return fmt.Errorf("insert user %d: %w", e.ID, err) } } return tx.Commit() } -func (s *SQLStore) FindByEmail(ctx context.Context, tenant, email string) ([]User, error) { - cipher := s.cipher(tenant) - term, err := UserFields.Email.Equality(ctx, cipher, email) +func (s *SQLStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, user User) error { + return s.Import(ctx, cipher, []User{user}) +} + +func (s *SQLStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { + query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err } - encrypted, err := s.query(ctx, selectUser+` WHERE email_eq = $1`, term) + encrypted, err := s.query(ctx, selectUser+` WHERE email = $1::eql_v3.query_text_search`, query) if err != nil { return nil, err } - return stackencrypt.Decrypt(ctx, cipher, encrypted) + return Decrypt(ctx, cipher, encrypted) } -// OldestFirst returns users aged minAge or over, oldest first. ORE terms -// compare in Go; a range scan inside the database needs EQL's ORE operators. -func (s *SQLStore) OldestFirst(ctx context.Context, tenant string, minAge uint32) ([]User, error) { - cipher := s.cipher(tenant) - floor, err := UserFields.Age.Ore(ctx, cipher, minAge) +// AtLeast returns users aged minAge or over, youngest first. Postgres compares +// and sorts the encrypted column through EQL's operators. +func (s *SQLStore) AtLeast(ctx context.Context, cipher *stackencrypt.Cipher, minAge int32) ([]User, error) { + query, err := Fields.Age.Query(ctx, cipher, minAge) if err != nil { return nil, err } - encrypted, err := s.query(ctx, selectUser) + encrypted, err := s.query(ctx, selectUser+` WHERE age >= $1::eql_v3.query_integer_ord ORDER BY age`, query) if err != nil { return nil, err } - encrypted = slices.DeleteFunc(encrypted, func(e EncryptedUser) bool { return e.Age.Ore.Compare(floor) < 0 }) - slices.SortFunc(encrypted, func(a, b EncryptedUser) int { return b.Age.Ore.Compare(a.Age.Ore) }) - return stackencrypt.Decrypt(ctx, cipher, encrypted) + return Decrypt(ctx, cipher, encrypted) } -func (s *SQLStore) WithRole(ctx context.Context, tenant, role string) ([]User, error) { - cipher := s.cipher(tenant) - contains, err := UserFields.Attrs.Contains(ctx, cipher, map[string]any{"role": role}) +func (s *SQLStore) WithRole(ctx context.Context, cipher *stackencrypt.Cipher, role string) ([]User, error) { + query, err := Fields.Attrs.Contains(ctx, cipher, map[string]any{"role": role}) if err != nil { return nil, err } - // Illustrative: the containment predicate is EQL's. - encrypted, err := s.query(ctx, selectUser+` WHERE attrs @> $1`, contains) + encrypted, err := s.query(ctx, selectUser+` WHERE attrs @> $1::eql_v3.query_json`, query) if err != nil { return nil, err } - return stackencrypt.Decrypt(ctx, cipher, encrypted) + return Decrypt(ctx, cipher, encrypted) } func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]EncryptedUser, error) { @@ -121,16 +104,10 @@ func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]Encryp var found []EncryptedUser for rs.Next() { var e EncryptedUser - if err := rs.Scan(&e.ID, &e.Email.Ciphertext, &e.Email.Equality, &e.Email.Match, - &e.Age.Ciphertext, &e.Age.Equality, &e.Age.Ore, &e.Attrs, &e.Notes); err != nil { + if err := rs.Scan(&e.ID, &e.Email, &e.Age, &e.Attrs, &e.Notes); err != nil { return nil, err } found = append(found, e) } return found, rs.Err() } - -func args(e EncryptedUser) []any { - return []any{e.ID, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, - e.Age.Ciphertext, e.Age.Equality, e.Age.Ore, e.Attrs, e.Notes} -} diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go new file mode 100644 index 000000000..4adba4951 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -0,0 +1,182 @@ +// Code generated by stashgen. DO NOT EDIT. + +package users + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" + "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// User prints its sealed fields in the clear: it has no String or LogValue +// method. Write them, or run stashgen with -redact. + +type EncryptedUser struct { + ID int64 `db:"id" gorm:"primaryKey"` + Email eql.TextSearch `db:"email"` + Age eql.IntegerOrd `db:"age"` + Attrs eql.JSON `db:"attrs"` + Notes eql.Text `db:"notes"` +} + +func (e EncryptedUser) String() string { + return gensupport.Redacted("EncryptedUser", map[string]any{"ID": e.ID}, "Email", "Age", "Attrs", "Notes") +} + +func (e EncryptedUser) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Age", "Attrs", "Notes") +} + +// Stops compiling when User gains, loses, reorders or retypes a field. +var _ = userShape(User{}) + +type userShape struct { + _ struct{} + ID int64 + Email string + Age int32 + Attrs map[string]any + Notes string + Internal string +} + +var declaration = gensupport.Declare("users"). + Passthrough("id"). + EncryptIndex("email", stackencrypt.Equality, stackencrypt.Ore, stackencrypt.Match()). + EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). + Index("attrs", stackencrypt.JSON()). + Encrypt("notes"). + Omit("internal") + +var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ + TypeName: "User", + Declaration: declaration, + PrintsPlaintext: true, + Source: func(v User) gensupport.Values { + return gensupport.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} + }, + Seal: func(v User, rec gensupport.Record) EncryptedUser { + return EncryptedUser{ + ID: v.ID, + Email: eql.NewTextSearch(rec["email"]), + Age: eql.NewIntegerOrd(rec["age"]), + Attrs: eql.NewJSON(rec["attrs"]), + Notes: eql.NewText(rec["notes"]), + } + }, + Open: func(e EncryptedUser) gensupport.Record { + return gensupport.Record{ + "email": e.Email.Outputs(), + "age": e.Age.Outputs(), + "attrs": e.Attrs.Outputs(), + "notes": e.Notes.Outputs(), + } + }, + Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { + v := User{ID: e.ID} + var err error + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return User{}, err + } + if v.Age, err = gensupport.Get[int32](vals, "age"); err != nil { + return User{}, err + } + if v.Attrs, err = gensupport.Get[map[string]any](vals, "attrs"); err != nil { + return User{}, err + } + if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { + return User{}, err + } + return v, nil + }, +}) + +// Encrypt seals every user in one ZeroKMS request. The result has one element +// for each user, in the same order. +func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, users []User) ([]EncryptedUser, error) { + return codec.Encrypt(ctx, cipher, users) +} + +// Decrypt opens every value in one ZeroKMS request. +func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedUser) ([]User, error) { + return codec.Decrypt(ctx, d, encrypted) +} + +// Encryption and Decryption describe the same work without running it, for +// stackencrypt.Batch2 and Batch3. +func Encryption(users []User) stackencrypt.Operation[[]EncryptedUser] { + return codec.Encryption(users) +} + +func Decryption(encrypted []EncryptedUser) stackencrypt.Operation[[]User] { + return codec.Decryption(encrypted) +} + +var Fields = struct { + Email EmailField + Age AgeField + Attrs AttrsField + Notes NotesField +}{ + Email: EmailField{gensupport.NewField[string](declaration, "email")}, + Age: AgeField{gensupport.NewField[int32](declaration, "age")}, + Attrs: AttrsField{gensupport.NewField[map[string]any](declaration, "attrs")}, + Notes: NotesField{gensupport.NewField[string](declaration, "notes")}, +} + +type EmailField struct { + field gensupport.Field[string] +} + +func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextSearch, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewTextSearch(out), err +} + +func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextSearchQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.NewTextSearchQuery(out), err +} + +type AgeField struct { + field gensupport.Field[int32] +} + +func (f AgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v int32) (eql.IntegerOrd, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewIntegerOrd(out), err +} + +func (f AgeField) Query(ctx context.Context, c *stackencrypt.Cipher, v int32) (eql.IntegerOrdQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.NewIntegerOrdQuery(out), err +} + +type AttrsField struct { + field gensupport.Field[map[string]any] +} + +func (f AttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any) (eql.JSON, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewJSON(out), err +} + +func (f AttrsField) Contains(ctx context.Context, c *stackencrypt.Cipher, v map[string]any) (eql.JSONQuery, error) { + out, err := f.field.Contains(ctx, c, v) + return eql.JSONQuery(out.JSON), err +} + +type NotesField struct { + field gensupport.Field[string] +} + +func (f NotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.Text, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewText(out), err +} From 6622c389f79e29b4fe22d80557bfea0f7407d3a6 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 17:25:39 +1100 Subject: [PATCH 11/22] docs(plans): name the model flag -model The flag that names a model was -record. The plan's glossary defines Record as the value sealed field by field, which is the generated type, so the flag named the wrong thing. The docs already call the struct a model, and the flag now uses the same word. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 8 ++++---- docs/plans/2026-10-04-plan-builder/contacts/contacts.go | 2 +- .../contacts/contactstash_stash.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 9170fbb06..9746a3c3f 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -741,7 +741,7 @@ With one EQL column for each field, that struct has the same fields as the gener A change to either struct stops the build. See [`users/sqlcstore.go`](2026-10-04-plan-builder/users/sqlcstore.go). -With separate columns, `-record Rows=ContactRow` names a model. +With separate columns, `-model Rows=ContactRow` names a model. Each field of the model carries a `stash` tag that names one output: `stash:"email"` is the ciphertext of `email`, and `stash:"email,equality"` is its equality term. `stashgen` writes `EncryptRows` and `DecryptRows`, which return and take the model. @@ -750,7 +750,7 @@ The generated file holds a copy of the model's fields, and it converts between t Go allows that conversion only while the two have the same fields, with the same types, in the same order. So a change to the model stops the build until `go generate` runs again. -For a model that cannot carry tags, `-record Rows=R:D` names a struct `D` in your own package that declares them. +For a model that cannot carry tags, `-model Rows=R:D` names a struct `D` in your own package that declares them. See [`contacts/contacts.go`](2026-10-04-plan-builder/contacts/contacts.go). ### Types in another package @@ -788,8 +788,8 @@ The compiler then finds a removed field and a field with a new type, and CI find | `-type T` | The struct that carries the `stash` tags. Required. | | `-name N` | Write `EncryptN`, `DecryptN` and `NFields`. | | `-for P.F` | `T` declares the tags for `F`, a type in another package. | -| `-record Name=R` | A model `R` for separate columns. Writes `EncryptName` and `DecryptName`. Any number. | -| `-record Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` declares them. | +| `-model Name=R` | A model `R` for separate columns. Writes `EncryptName` and `DecryptName`. Any number. | +| `-model Name=R:D` | The same, for an `R` that cannot carry tags. The struct `D` declares them. | | `-redact` | Write `String` and `LogValue` methods on `T`. | | `-output file` | The file to write. The default is the type's name in lower case, with `_stash.go`. | diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go index aaecd54d9..2d6fa7df1 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -//go:generate go tool stashgen -type contactStash -for crm.Contact -record Rows=ContactRow +//go:generate go tool stashgen -type contactStash -for crm.Contact -model Rows=ContactRow // contactStash declares the tags for crm.Contact, which cannot carry them. // stashgen matches each field to the crm.Contact field with the same name and diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index 14585b9a6..22e2edb27 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -152,7 +152,7 @@ func (f PhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, return f.field.Equality(ctx, c, v) } -// The -record flag: ContactRow converts to and from its shape only while the +// The -model flag: ContactRow converts to and from its shape only while the // two have the same fields, with the same types, in the same order. type rowsShape struct { ID int64 From 29446f617610d42a2fb8072aca531a07ac2eb312 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 17:28:45 +1100 Subject: [PATCH 12/22] docs(plans): one Batch that writes to destinations Batch2 and Batch3 put two or three types in one ZeroKMS request, with one numbered function for each count. Go cannot write one function that takes any number of differently typed arguments and returns each result with its own type, so the count was in the name. That stopped at three and looked like nothing else in Go. Batch takes any number of operations. Each operation names the variable that gets its result, as rows.Scan and json.Unmarshal do, and the compiler checks the variable's type. The generated functions are EncryptInto and DecryptInto. A batch object that a caller queues work onto and then runs, as pgx has, was not used: a caller can forget to run it, and this design removes that kind of mistake everywhere else. The examples pass go vet against the stub, and the seven mutation checks give the same results as before. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 23 ++++++++++++------- .../accounts/account_stash.go | 8 +++---- .../contacts/contactstash_stash.go | 8 +++---- .../documents/document_stash.go | 8 +++---- .../individuals/individual_stash.go | 8 +++---- docs/plans/2026-10-04-plan-builder/main.go | 8 +++++-- .../users/user_stash.go | 12 +++++----- 7 files changed, 43 insertions(+), 32 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 9746a3c3f..bfc3f72ea 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -620,8 +620,8 @@ For `-type User`, the file `user_stash.go` holds: - **`Fields`.** It has one entry for each sealed field. An entry encrypts one value of that field, and it has a query method only for what the field declares. -- **`Encryption` and `Decryption`.** - They describe the same work without running it, for a batch. +- **`EncryptInto` and `DecryptInto`.** + They describe the same work for a batch, and name the variable that gets the result. - **Print methods on `EncryptedUser`.** `String` and `LogValue` print the passthrough fields and hide the sealed ones. - **The declaration.** @@ -641,8 +641,9 @@ See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_ | `users.Fields.Email.Query(ctx, c, v string)` | an EQL query value, for a field with `encrypt_into` | | `users.Fields.Email.Equality`, `.Match`, `.Ore`, `.Ope` | one term, for a field with `index=` | | `users.Fields.Attrs.Contains(ctx, c, v)` | a JSON containment query | -| `users.Encryption(vs []User)`, `users.Decryption(es []EncryptedUser)` | a `stackencrypt.Operation`, for a batch | -| `stackencrypt.Batch2(ctx, c, a, b)`, `Batch3` | the result of each operation | +| `users.EncryptInto(dst *[]EncryptedUser, vs []User)` | a `stackencrypt.Operation`, for a batch | +| `users.DecryptInto(dst *[]User, es []EncryptedUser)` | a `stackencrypt.Operation`, for a batch | +| `stackencrypt.Batch(ctx, c, ops ...Operation)` | nothing; it writes each result to its `dst` | Every call also returns an `error`. `Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. @@ -652,12 +653,18 @@ For one value, pass a slice with one element. A field entry's `Encrypt` and its query methods take one value. A term derives with no request, so there is nothing to batch. -`Batch2` and `Batch3` run operations on two or three types in one ZeroKMS request. -Each returns one typed result for each operation. +`Batch` runs operations on any number of types in one ZeroKMS request. +It writes each result to the variable that the operation names. +The compiler checks that the variable has the result's type. ```go -encryptedUsers, encryptedContacts, err := stackencrypt.Batch2(ctx, cipher, - users.Encryption(people), contacts.Encryption(list)) +var encryptedUsers []users.EncryptedUser +var encryptedContacts []contacts.EncryptedContact + +err := stackencrypt.Batch(ctx, cipher, + users.EncryptInto(&encryptedUsers, people), + contacts.EncryptInto(&encryptedContacts, list), +) ``` `*Client` and `*Cipher` both implement `Decrypter`. diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go index bb05ad194..c7523f3f5 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -89,12 +89,12 @@ func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []Encrypte return codec.Decrypt(ctx, d, encrypted) } -func Encryption(accounts []Account) stackencrypt.Operation[[]EncryptedAccount] { - return codec.Encryption(accounts) +func EncryptInto(dst *[]EncryptedAccount, accounts []Account) stackencrypt.Operation { + return codec.EncryptInto(dst, accounts) } -func Decryption(encrypted []EncryptedAccount) stackencrypt.Operation[[]Account] { - return codec.Decryption(encrypted) +func DecryptInto(dst *[]Account, encrypted []EncryptedAccount) stackencrypt.Operation { + return codec.DecryptInto(dst, encrypted) } var Fields = struct { diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index 22e2edb27..8fe6bf37c 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -100,12 +100,12 @@ func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []Encrypte return codec.Decrypt(ctx, d, encrypted) } -func Encryption(contacts []crm.Contact) stackencrypt.Operation[[]EncryptedContact] { - return codec.Encryption(contacts) +func EncryptInto(dst *[]EncryptedContact, contacts []crm.Contact) stackencrypt.Operation { + return codec.EncryptInto(dst, contacts) } -func Decryption(encrypted []EncryptedContact) stackencrypt.Operation[[]crm.Contact] { - return codec.Decryption(encrypted) +func DecryptInto(dst *[]crm.Contact, encrypted []EncryptedContact) stackencrypt.Operation { + return codec.DecryptInto(dst, encrypted) } var Fields = struct { diff --git a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go index aa7641e03..c523d951b 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go +++ b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go @@ -80,10 +80,10 @@ func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []Encrypte return codec.Decrypt(ctx, d, encrypted) } -func Encryption(documents []Document) stackencrypt.Operation[[]EncryptedDocument] { - return codec.Encryption(documents) +func EncryptInto(dst *[]EncryptedDocument, documents []Document) stackencrypt.Operation { + return codec.EncryptInto(dst, documents) } -func Decryption(encrypted []EncryptedDocument) stackencrypt.Operation[[]Document] { - return codec.Decryption(encrypted) +func DecryptInto(dst *[]Document, encrypted []EncryptedDocument) stackencrypt.Operation { + return codec.DecryptInto(dst, encrypted) } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index c392a147d..3675f4d32 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -115,12 +115,12 @@ func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []Encrypte return codec.Decrypt(ctx, d, encrypted) } -func Encryption(individuals []Individual) stackencrypt.Operation[[]EncryptedIndividual] { - return codec.Encryption(individuals) +func EncryptInto(dst *[]EncryptedIndividual, individuals []Individual) stackencrypt.Operation { + return codec.EncryptInto(dst, individuals) } -func Decryption(encrypted []EncryptedIndividual) stackencrypt.Operation[[]Individual] { - return codec.Decryption(encrypted) +func DecryptInto(dst *[]Individual, encrypted []EncryptedIndividual) stackencrypt.Operation { + return codec.DecryptInto(dst, encrypted) } var Fields = struct { diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go index 4e8c7cc2a..fed81c160 100644 --- a/docs/plans/2026-10-04-plan-builder/main.go +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -75,8 +75,12 @@ func run(ctx context.Context) error { // Two types in one ZeroKMS request. list := []crm.Contact{{ID: 9, Email: "dan@example.com", PhoneNumber: "+61 400 000 000"}} - encryptedUsers, encryptedContacts, err := stackencrypt.Batch2(ctx, cipher, - users.Encryption(newHires), contacts.Encryption(list)) + var encryptedUsers []users.EncryptedUser + var encryptedContacts []contacts.EncryptedContact + err = stackencrypt.Batch(ctx, cipher, + users.EncryptInto(&encryptedUsers, newHires), + contacts.EncryptInto(&encryptedContacts, list), + ) if err != nil { return err } diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index 4adba4951..358d736ce 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -108,14 +108,14 @@ func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []Encrypte return codec.Decrypt(ctx, d, encrypted) } -// Encryption and Decryption describe the same work without running it, for -// stackencrypt.Batch2 and Batch3. -func Encryption(users []User) stackencrypt.Operation[[]EncryptedUser] { - return codec.Encryption(users) +// EncryptInto and DecryptInto describe the same work for stackencrypt.Batch, +// which runs it and writes the result to dst. +func EncryptInto(dst *[]EncryptedUser, users []User) stackencrypt.Operation { + return codec.EncryptInto(dst, users) } -func Decryption(encrypted []EncryptedUser) stackencrypt.Operation[[]User] { - return codec.Decryption(encrypted) +func DecryptInto(dst *[]User, encrypted []EncryptedUser) stackencrypt.Operation { + return codec.DecryptInto(dst, encrypted) } var Fields = struct { From f11111e954300e4febca94b61eb43076e51cffa0 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 18:00:39 +1100 Subject: [PATCH 13/22] docs(plans): EQL type names from the catalog, and what works today Settles the Go EQL names, and makes the examples show an EQL type the engine can produce. The plan states the design; the reasons are here. The names. The EQL catalog (packages/eql/crates/eql-domains) is the single source of every EQL type, and one function in it turns a domain into a type name for the Rust and TypeScript generators. The Go types take the same names, so TextEq is TextEq in every language. The package is stackencrypt/eql, a query type is the name plus Query, and the value of encrypt_into is the Go type name. The last commit marked all of this as a placeholder waiting on someone else. It was not: the names already existed in the repository. JSON is the one exception. The catalog says Json and Go convention says JSON. Go idiom wins here, because a Go reader meets this name in every struct that uses it. eql-codegen writes the Go package from the catalog, as it writes the Rust and TypeScript types. A hand-copied list of eleven families and their suffixes would drift. Two errors, found by reading the catalog: - The users example declared the wrong terms. It gave TextSearch equality, ORE and match, and IntegerOrd equality and ORE. The catalog says TextSearch is equality, OPE and match, and IntegerOrd is OPE alone. Those were written from memory. - The examples used four EQL types the engine cannot produce. eql-codegen lists the domains stack-encrypt can encrypt into, and the list has one entry: text equality. The others wait on plaintext encodings for the non-text families, and on ordering and match terms in EQL's form. So the plan now says which EQL type works today, stashgen refuses the rest, and the users example is rebuilt on TextEq: two TextEq fields and one search by email. A field that needs an ordering or a match search today uses separate columns, which the contacts example shows. The "Go EQL names" open question is removed, because it is decided. Every Go file passes `go vet ./...` against the uncommitted stub, and the generate program passes under `-tags stashgen`; gofmt reports nothing. The seven mutation checks give the same results on the rebuilt files. The sqlc package is real output for the new schema. The amended text passes slipstream's checks with 0 findings, and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 49 ++++++++-- docs/plans/2026-10-04-plan-builder/README.md | 10 +- .../internal/userdb/models.go | 6 +- .../internal/userdb/query.sql.go | 83 +++------------- docs/plans/2026-10-04-plan-builder/main.go | 24 +---- .../sqlc/eql-domains.sql | 9 +- .../2026-10-04-plan-builder/sqlc/query.sql | 9 +- .../2026-10-04-plan-builder/sqlc/schema.sql | 8 +- .../2026-10-04-plan-builder/sqlc/sqlc.yaml | 16 +--- .../users/gormstore.go | 2 +- .../2026-10-04-plan-builder/users/model.go | 12 +-- .../users/sqlcstore.go | 16 ++-- .../2026-10-04-plan-builder/users/sqlstore.go | 55 +++-------- .../users/user_stash.go | 96 +++++-------------- 14 files changed, 127 insertions(+), 268 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index bfc3f72ea..a88da0f9f 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -517,15 +517,15 @@ A program calls those functions, and it never builds or names a plan. type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt_into=TextSearch"` - Age int32 `stash:"age,encrypt_into=IntegerOrd"` + Email string `stash:"email,encrypt_into=TextEq"` + Name string `stash:"name,encrypt_into=TextEq"` } cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) encrypted, err := users.Encrypt(ctx, cipher, people) // []users.EncryptedUser, one ZeroKMS request people, err := users.Decrypt(ctx, cipher, encrypted) // []users.User -query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com") // eql.TextSearchQuery +query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com") // eql.TextEqQuery ``` ### Use the SDK @@ -576,7 +576,7 @@ The first part of a tag is the field's name, which is the column name in a datab | Tag | Meaning | |---|---| | `` _ struct{} `stash:"context=users"` `` | the context of every field in the struct | -| `stash:"email,encrypt_into=TextSearch"` | seal the field into one EQL value, with the indexes that EQL type has | +| `stash:"email,encrypt_into=TextEq"` | seal the field into one EQL value, with the terms that EQL type has | | `stash:"notes,encrypt"` | seal the field, with no index | | `stash:"email,encrypt,index=equality;match"` | seal the field, and derive each index beside it | | `stash:"attrs,index=json"` | derive the index alone | @@ -607,6 +607,42 @@ The generated field is then a struct with one field for each output, such as `Em Every sealed field gets such a struct, including a field with one output. A library that maps one struct field to one column needs a model for this layout. +### EQL types + +An EQL type is the Go type of one EQL column. +`eql-codegen` writes the package `stackencrypt/eql` from the EQL catalog. +The same catalog gives the Rust and TypeScript types, so a type has one name in every language, such as `TextEq`. +The JSON type is the exception: its Go name is `JSON`. + +A type name is a family and a suffix. +The families are `Text`, `Integer`, `Smallint`, `Bigint`, `Numeric`, `Real`, `Double`, `Date`, `Timestamp`, `Boolean` and `JSON`. +The suffix says what a query can do: + +| Suffix | Terms | Operators | +|---|---|---| +| none | none | none; the value is stored and read | +| `Eq` | equality | `=` `<>` | +| `Ord`, `OrdOpe` | OPE | `=` `<>` `<` `<=` `>` `>=` | +| `OrdOre` | ORE | `=` `<>` `<` `<=` `>` `>=` | +| `Match` | match | `@@` | +| `Search` | equality, OPE and match | all of them | +| `SearchOre` | equality, ORE and match | all of them | + +In the `Text` family, the three `Ord` suffixes carry equality too. +`Match`, `Search` and `SearchOre` are for `Text` only, and `Boolean` has no suffix. + +Each type has a query type, with `Query` after its name: `TextEqQuery`. +The value of `encrypt_into` is the Go type name. + +The engine produces one EQL type today: `TextEq`. +The other types wait for two pieces of work in the engine: + +- how the number, date, boolean and JSON families encode a plaintext; +- ordering and match terms in the form that EQL stores. + +`stashgen` refuses an EQL type that the engine cannot produce. +For a field that needs an ordering or a match search today, use separate columns. + ### What stashgen writes For `-type User`, the file `user_stash.go` holds: @@ -640,7 +676,6 @@ See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_ | `users.Fields.Email.Encrypt(ctx, c, v string)` | the field's generated type, for an update of one column | | `users.Fields.Email.Query(ctx, c, v string)` | an EQL query value, for a field with `encrypt_into` | | `users.Fields.Email.Equality`, `.Match`, `.Ore`, `.Ope` | one term, for a field with `index=` | -| `users.Fields.Attrs.Contains(ctx, c, v)` | a JSON containment query | | `users.EncryptInto(dst *[]EncryptedUser, vs []User)` | a `stackencrypt.Operation`, for a batch | | `users.DecryptInto(dst *[]User, es []EncryptedUser)` | a `stackencrypt.Operation`, for a batch | | `stackencrypt.Batch(ctx, c, ops ...Operation)` | nothing; it writes each result to its `dst` | @@ -812,6 +847,7 @@ The same input always gives the same file: fields keep their declared order, and - a struct with no `context=` field; - an index or an EQL type that does not apply to the field's Go type, such as `match` on an `int32`; - a field type that the engine cannot seal; +- an EQL type that the engine cannot produce yet; - a `passthrough` field that has an index; - a model with a field that has no tag, or with no field for an output; - two structs in one package that would both write `Encrypt`. @@ -1126,9 +1162,6 @@ Then: ## Open questions -- **The Go EQL names.** - The package `eql`, its type names and the values of `encrypt_into` are placeholders. - The EQL typed verb (#1062) settles them. - **Query building in Go.** The SDK gives the values for a search, and the program writes the SQL. A design for building that SQL is separate work. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index 65e33e05c..1a913d190 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -11,7 +11,7 @@ The module path is `example.com/app`. | [`main.go`](main.go) | A client, one cipher for each tenant, and a batch of two types in one request | | [`users/model.go`](users/model.go) | A struct with `stash` tags, and the `go:generate` line | | [`users/user_stash.go`](users/user_stash.go) | The file `stashgen` writes: the encrypted type, `Encrypt`, `Decrypt` and `Fields` | -| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql`: batch insert in a transaction, and three searches | +| [`users/sqlstore.go`](users/sqlstore.go) | `database/sql`: batch insert in a transaction, and a search by email | | [`users/gormstore.go`](users/gormstore.go) | GORM, with the generated type as the model | | [`users/sqlcstore.go`](users/sqlcstore.go) | sqlc, with its row struct converted to the generated type | | [`sqlc/`](sqlc/) | The schema, the queries and the overrides that generate [`internal/userdb/`](internal/userdb/) | @@ -23,6 +23,8 @@ The module path is `example.com/app`. | [`individuals/`](individuals/) | A type with no tags, and the file the policy gives | | [`individualstore/store.go`](individualstore/store.go) | A store that uses that generated type | +The `users` example uses `TextEq`, which is the one EQL type the engine produces today. + ## What was checked | Claim | Status | @@ -36,7 +38,7 @@ The module path is `example.com/app`. | The files `stashgen` writes | Not run. `stashgen` does not exist, and the five `_stash.go` files are written by hand. | | The SDK can be built with these signatures | Not run. | | The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. | -| The EQL types, and how generated code assembles them | Not run. The package name and type names are placeholders. | +| The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | | The steps in "Use the SDK" | Not run. Nobody has followed them. | ## Generate the sqlc package @@ -52,8 +54,8 @@ Three rules apply when a column has an EQL type: sqlc cannot parse the EQL install bundle, because it rejects function overloads that differ only by `text` and `text[]`. The file [`sqlc/eql-domains.sql`](sqlc/eql-domains.sql) declares each domain as `jsonb`, and the database still gets the real bundle from `stash eql install`. - **Spell a `db_type` override exactly as the schema spells the type.** - `public.eql_v3_text_search` and `eql_v3_text_search` are two different spellings to sqlc. + `public.eql_v3_text_eq` and `eql_v3_text_eq` are two different spellings to sqlc. An override with the other spelling matches nothing, and the column becomes `interface{}`. - **Cast a query parameter once, straight to the query domain.** sqlc types a parameter by its first cast. - `$1::eql_v3.query_text_search` gets the override type, and `$1::jsonb::eql_v3.query_text_search` gets `json.RawMessage`. + `$1::eql_v3.query_text_eq` gets the override type, and `$1::jsonb::eql_v3.query_text_eq` gets `json.RawMessage`. diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go index ddea2ddb0..179dfd37b 100644 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go @@ -10,8 +10,6 @@ import ( type User struct { ID int64 - Email eql.TextSearch - Age eql.IntegerOrd - Attrs eql.JSON - Notes eql.Text + Email eql.TextEq + Name eql.TextEq } diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go index a5bf23b51..c5e4bb45c 100644 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go @@ -12,35 +12,27 @@ import ( ) const createUser = `-- name: CreateUser :exec -INSERT INTO users (id, email, age, attrs, notes) VALUES ($1, $2, $3, $4, $5) +INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ` type CreateUserParams struct { ID int64 - Email eql.TextSearch - Age eql.IntegerOrd - Attrs eql.JSON - Notes eql.Text + Email eql.TextEq + Name eql.TextEq } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error { - _, err := q.db.ExecContext(ctx, createUser, - arg.ID, - arg.Email, - arg.Age, - arg.Attrs, - arg.Notes, - ) + _, err := q.db.ExecContext(ctx, createUser, arg.ID, arg.Email, arg.Name) return err } const findUsersByEmail = `-- name: FindUsersByEmail :many -SELECT id, email, age, attrs, notes FROM users WHERE email = $1::eql_v3.query_text_search +SELECT id, email, name FROM users WHERE email = $1::eql_v3.query_text_eq ` // One cast, straight to the query domain: sqlc types a parameter by its first -// cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. -func (q *Queries) FindUsersByEmail(ctx context.Context, email eql.TextSearchQuery) ([]User, error) { +// cast, so ::jsonb::eql_v3.query_text_eq would generate json.RawMessage. +func (q *Queries) FindUsersByEmail(ctx context.Context, email eql.TextEqQuery) ([]User, error) { rows, err := q.db.QueryContext(ctx, findUsersByEmail, email) if err != nil { return nil, err @@ -49,13 +41,7 @@ func (q *Queries) FindUsersByEmail(ctx context.Context, email eql.TextSearchQuer var items []User for rows.Next() { var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - &i.Notes, - ); err != nil { + if err := rows.Scan(&i.ID, &i.Email, &i.Name); err != nil { return nil, err } items = append(items, i) @@ -70,24 +56,18 @@ func (q *Queries) FindUsersByEmail(ctx context.Context, email eql.TextSearchQuer } const getUser = `-- name: GetUser :one -SELECT id, email, age, attrs, notes FROM users WHERE id = $1 +SELECT id, email, name FROM users WHERE id = $1 ` func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { row := q.db.QueryRowContext(ctx, getUser, id) var i User - err := row.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - &i.Notes, - ) + err := row.Scan(&i.ID, &i.Email, &i.Name) return i, err } const listUsers = `-- name: ListUsers :many -SELECT id, email, age, attrs, notes FROM users ORDER BY id +SELECT id, email, name FROM users ORDER BY id ` func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { @@ -99,46 +79,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { var items []User for rows.Next() { var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - &i.Notes, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUsersAtLeast = `-- name: ListUsersAtLeast :many -SELECT id, email, age, attrs, notes FROM users WHERE age >= $1::eql_v3.query_integer_ord ORDER BY age -` - -func (q *Queries) ListUsersAtLeast(ctx context.Context, minAge eql.IntegerOrdQuery) ([]User, error) { - rows, err := q.db.QueryContext(ctx, listUsersAtLeast, minAge) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Age, - &i.Attrs, - &i.Notes, - ); err != nil { + if err := rows.Scan(&i.ID, &i.Email, &i.Name); err != nil { return nil, err } items = append(items, i) diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go index fed81c160..98c848d5d 100644 --- a/docs/plans/2026-10-04-plan-builder/main.go +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -40,17 +40,10 @@ func run(ctx context.Context) error { cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")).Extend("tenant-42") store := users.NewSQLStore(db) - alice := users.User{ - ID: 1, - Email: "alice@example.com", - Age: 34, - Attrs: map[string]any{"role": "admin", "team": "payments"}, - Notes: "Prefers email.", - Internal: "never stored", - } + alice := users.User{ID: 1, Email: "alice@example.com", Name: "Alice Ng", Internal: "never stored"} newHires := []users.User{ - {ID: 2, Email: "bob@example.com", Age: 17, Attrs: map[string]any{"role": "intern"}, Notes: "Starts Monday."}, - {ID: 3, Email: "carol@example.com", Age: 52, Attrs: map[string]any{"role": "admin"}}, + {ID: 2, Email: "bob@example.com", Name: "Bob Tran"}, + {ID: 3, Email: "carol@example.com", Name: "Carol Diaz"}, } if err := store.Create(ctx, cipher, alice); err != nil { @@ -64,14 +57,6 @@ func run(ctx context.Context) error { if err != nil { return err } - admins, err := store.WithRole(ctx, cipher, "admin") - if err != nil { - return err - } - adults, err := store.AtLeast(ctx, cipher, 18) - if err != nil { - return err - } // Two types in one ZeroKMS request. list := []crm.Contact{{ID: 9, Email: "dan@example.com", PhoneNumber: "+61 400 000 000"}} @@ -94,8 +79,7 @@ func run(ctx context.Context) error { } // Print ids and counts only. Every other value here is plaintext. - fmt.Println("bob:", ids(bobs), "admins:", ids(admins), "adults:", ids(adults), - "batched:", len(encryptedUsers), len(encryptedContacts)) + fmt.Println("bob:", ids(bobs), "batched:", len(encryptedUsers), len(encryptedContacts)) return nil } diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql b/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql index 5203b5f40..7f1a04f13 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/eql-domains.sql @@ -2,10 +2,5 @@ -- exactly as schema.sql and query.sql do, or its db_type override never matches. CREATE SCHEMA eql_v3; -CREATE DOMAIN public.eql_v3_text_search AS jsonb; -CREATE DOMAIN public.eql_v3_integer_ord AS jsonb; -CREATE DOMAIN public.eql_v3_json_search AS jsonb; -CREATE DOMAIN public.eql_v3_text AS jsonb; - -CREATE DOMAIN eql_v3.query_text_search AS jsonb; -CREATE DOMAIN eql_v3.query_integer_ord AS jsonb; +CREATE DOMAIN public.eql_v3_text_eq AS jsonb; +CREATE DOMAIN eql_v3.query_text_eq AS jsonb; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql index f29d5f87a..46e1bd03b 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/query.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/query.sql @@ -1,5 +1,5 @@ -- name: CreateUser :exec -INSERT INTO users (id, email, age, attrs, notes) VALUES ($1, $2, $3, $4, $5); +INSERT INTO users (id, email, name) VALUES ($1, $2, $3); -- name: GetUser :one SELECT * FROM users WHERE id = $1; @@ -8,9 +8,6 @@ SELECT * FROM users WHERE id = $1; SELECT * FROM users ORDER BY id; -- One cast, straight to the query domain: sqlc types a parameter by its first --- cast, so ::jsonb::eql_v3.query_text_search would generate json.RawMessage. +-- cast, so ::jsonb::eql_v3.query_text_eq would generate json.RawMessage. -- name: FindUsersByEmail :many -SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_search; - --- name: ListUsersAtLeast :many -SELECT * FROM users WHERE age >= sqlc.arg(min_age)::eql_v3.query_integer_ord ORDER BY age; +SELECT * FROM users WHERE email = sqlc.arg(email)::eql_v3.query_text_eq; diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql index 3e7d1a606..161dedaec 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql +++ b/docs/plans/2026-10-04-plan-builder/sqlc/schema.sql @@ -1,7 +1,5 @@ CREATE TABLE users ( - id bigint PRIMARY KEY, - email public.eql_v3_text_search NOT NULL, - age public.eql_v3_integer_ord NOT NULL, - attrs public.eql_v3_json_search NOT NULL, - notes public.eql_v3_text NOT NULL + id bigint PRIMARY KEY, + email public.eql_v3_text_eq NOT NULL, + name public.eql_v3_text_eq NOT NULL ); diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml index 82f23b053..771802016 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml +++ b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml @@ -10,15 +10,7 @@ sql: package: userdb out: ../internal/userdb overrides: - - db_type: "public.eql_v3_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextSearch } - - db_type: "public.eql_v3_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: IntegerOrd } - - db_type: "public.eql_v3_json_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: JSON } - - db_type: "public.eql_v3_text" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: Text } - - db_type: "eql_v3.query_text_search" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextSearchQuery } - - db_type: "eql_v3.query_integer_ord" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: IntegerOrdQuery } + - db_type: "public.eql_v3_text_eq" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextEq } + - db_type: "eql_v3.query_text_eq" + go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextEqQuery } diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index 10f375bbb..50c3bbf25 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -36,7 +36,7 @@ func (s *GormStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher return nil, err } var encrypted []EncryptedUser - err = s.db.WithContext(ctx).Where("email = ?::eql_v3.query_text_search", query).Find(&encrypted).Error + err = s.db.WithContext(ctx).Where("email = ?::eql_v3.query_text_eq", query).Find(&encrypted).Error if err != nil { return nil, err } diff --git a/docs/plans/2026-10-04-plan-builder/users/model.go b/docs/plans/2026-10-04-plan-builder/users/model.go index 4ed7d8f92..2c1d23961 100644 --- a/docs/plans/2026-10-04-plan-builder/users/model.go +++ b/docs/plans/2026-10-04-plan-builder/users/model.go @@ -5,11 +5,9 @@ package users // Every exported field needs a stash tag, so a new field cannot reach the // database unencrypted by accident. type User struct { - _ struct{} `stash:"context=users"` - ID int64 `stash:"id,passthrough" db:"id" gorm:"primaryKey"` - Email string `stash:"email,encrypt_into=TextSearch" db:"email"` - Age int32 `stash:"age,encrypt_into=IntegerOrd" db:"age"` - Attrs map[string]any `stash:"attrs,encrypt_into=JSON" db:"attrs"` - Notes string `stash:"notes,encrypt_into=Text" db:"notes"` - Internal string `stash:"-"` + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough" db:"id" gorm:"primaryKey"` + Email string `stash:"email,encrypt_into=TextEq" db:"email"` + Name string `stash:"name,encrypt_into=TextEq" db:"name"` + Internal string `stash:"-"` } diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index 0fbd06e73..f8f44a25b 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -69,11 +69,15 @@ func (s *SQLCStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher if err != nil { return nil, err } - return Decrypt(ctx, cipher, fromRows(rows)) + encrypted := make([]EncryptedUser, len(rows)) + for i, row := range rows { + encrypted[i] = EncryptedUser(row) + } + return Decrypt(ctx, cipher, encrypted) } // ChangeEmail rewrites one field. One EQL column holds the ciphertext and -// every term, so they cannot go out of step. +// its term, so they cannot go out of step. func (s *SQLCStore) ChangeEmail(ctx context.Context, cipher *stackencrypt.Cipher, id int64, email string) error { sealed, err := Fields.Email.Encrypt(ctx, cipher, email) if err != nil { @@ -82,11 +86,3 @@ func (s *SQLCStore) ChangeEmail(ctx context.Context, cipher *stackencrypt.Cipher _, err = s.db.ExecContext(ctx, `UPDATE users SET email = $2 WHERE id = $1`, id, sealed) return err } - -func fromRows(rows []userdb.User) []EncryptedUser { - encrypted := make([]EncryptedUser, len(rows)) - for i, row := range rows { - encrypted[i] = EncryptedUser(row) - } - return encrypted -} diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go index d1ab0a6e1..b00ab319f 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -9,8 +9,8 @@ import ( ) const ( - columns = `id, email, age, attrs, notes` - insertUser = `INSERT INTO users (` + columns + `) VALUES ($1, $2, $3, $4, $5)` + columns = `id, email, name` + insertUser = `INSERT INTO users (` + columns + `) VALUES ($1, $2, $3)` selectUser = `SELECT ` + columns + ` FROM users` ) @@ -45,7 +45,7 @@ func (s *SQLStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, peop defer stmt.Close() for _, e := range encrypted { - if _, err := stmt.ExecContext(ctx, e.ID, e.Email, e.Age, e.Attrs, e.Notes); err != nil { + if _, err := stmt.ExecContext(ctx, e.ID, e.Email, e.Name); err != nil { return fmt.Errorf("insert user %d: %w", e.ID, err) } } @@ -56,58 +56,29 @@ func (s *SQLStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, user return s.Import(ctx, cipher, []User{user}) } +// FindByEmail matches the whole address. Postgres compares the encrypted +// column through EQL's = operator. func (s *SQLStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err } - encrypted, err := s.query(ctx, selectUser+` WHERE email = $1::eql_v3.query_text_search`, query) - if err != nil { - return nil, err - } - return Decrypt(ctx, cipher, encrypted) -} - -// AtLeast returns users aged minAge or over, youngest first. Postgres compares -// and sorts the encrypted column through EQL's operators. -func (s *SQLStore) AtLeast(ctx context.Context, cipher *stackencrypt.Cipher, minAge int32) ([]User, error) { - query, err := Fields.Age.Query(ctx, cipher, minAge) - if err != nil { - return nil, err - } - encrypted, err := s.query(ctx, selectUser+` WHERE age >= $1::eql_v3.query_integer_ord ORDER BY age`, query) - if err != nil { - return nil, err - } - return Decrypt(ctx, cipher, encrypted) -} - -func (s *SQLStore) WithRole(ctx context.Context, cipher *stackencrypt.Cipher, role string) ([]User, error) { - query, err := Fields.Attrs.Contains(ctx, cipher, map[string]any{"role": role}) - if err != nil { - return nil, err - } - encrypted, err := s.query(ctx, selectUser+` WHERE attrs @> $1::eql_v3.query_json`, query) - if err != nil { - return nil, err - } - return Decrypt(ctx, cipher, encrypted) -} - -func (s *SQLStore) query(ctx context.Context, q string, params ...any) ([]EncryptedUser, error) { - rs, err := s.db.QueryContext(ctx, q, params...) + rs, err := s.db.QueryContext(ctx, selectUser+` WHERE email = $1::eql_v3.query_text_eq`, query) if err != nil { return nil, err } defer rs.Close() - var found []EncryptedUser + var encrypted []EncryptedUser for rs.Next() { var e EncryptedUser - if err := rs.Scan(&e.ID, &e.Email, &e.Age, &e.Attrs, &e.Notes); err != nil { + if err := rs.Scan(&e.ID, &e.Email, &e.Name); err != nil { return nil, err } - found = append(found, e) + encrypted = append(encrypted, e) } - return found, rs.Err() + if err := rs.Err(); err != nil { + return nil, err + } + return Decrypt(ctx, cipher, encrypted) } diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index 358d736ce..083e72c69 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -18,19 +18,17 @@ const _ = gensupport.GeneratedVersion1 // method. Write them, or run stashgen with -redact. type EncryptedUser struct { - ID int64 `db:"id" gorm:"primaryKey"` - Email eql.TextSearch `db:"email"` - Age eql.IntegerOrd `db:"age"` - Attrs eql.JSON `db:"attrs"` - Notes eql.Text `db:"notes"` + ID int64 `db:"id" gorm:"primaryKey"` + Email eql.TextEq `db:"email"` + Name eql.TextEq `db:"name"` } func (e EncryptedUser) String() string { - return gensupport.Redacted("EncryptedUser", map[string]any{"ID": e.ID}, "Email", "Age", "Attrs", "Notes") + return gensupport.Redacted("EncryptedUser", map[string]any{"ID": e.ID}, "Email", "Name") } func (e EncryptedUser) LogValue() slog.Value { - return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Age", "Attrs", "Notes") + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Name") } // Stops compiling when User gains, loses, reorders or retypes a field. @@ -40,18 +38,14 @@ type userShape struct { _ struct{} ID int64 Email string - Age int32 - Attrs map[string]any - Notes string + Name string Internal string } var declaration = gensupport.Declare("users"). Passthrough("id"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Ore, stackencrypt.Match()). - EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). - Index("attrs", stackencrypt.JSON()). - Encrypt("notes"). + EncryptIndex("email", stackencrypt.Equality). + EncryptIndex("name", stackencrypt.Equality). Omit("internal") var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ @@ -59,24 +53,17 @@ var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ Declaration: declaration, PrintsPlaintext: true, Source: func(v User) gensupport.Values { - return gensupport.Values{"email": v.Email, "age": v.Age, "attrs": v.Attrs, "notes": v.Notes} + return gensupport.Values{"email": v.Email, "name": v.Name} }, Seal: func(v User, rec gensupport.Record) EncryptedUser { return EncryptedUser{ ID: v.ID, - Email: eql.NewTextSearch(rec["email"]), - Age: eql.NewIntegerOrd(rec["age"]), - Attrs: eql.NewJSON(rec["attrs"]), - Notes: eql.NewText(rec["notes"]), + Email: eql.NewTextEq(rec["email"]), + Name: eql.NewTextEq(rec["name"]), } }, Open: func(e EncryptedUser) gensupport.Record { - return gensupport.Record{ - "email": e.Email.Outputs(), - "age": e.Age.Outputs(), - "attrs": e.Attrs.Outputs(), - "notes": e.Notes.Outputs(), - } + return gensupport.Record{"email": e.Email.Outputs(), "name": e.Name.Outputs()} }, Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { v := User{ID: e.ID} @@ -84,13 +71,7 @@ var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return User{}, err } - if v.Age, err = gensupport.Get[int32](vals, "age"); err != nil { - return User{}, err - } - if v.Attrs, err = gensupport.Get[map[string]any](vals, "attrs"); err != nil { - return User{}, err - } - if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { + if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { return User{}, err } return v, nil @@ -120,63 +101,36 @@ func DecryptInto(dst *[]User, encrypted []EncryptedUser) stackencrypt.Operation var Fields = struct { Email EmailField - Age AgeField - Attrs AttrsField - Notes NotesField + Name NameField }{ Email: EmailField{gensupport.NewField[string](declaration, "email")}, - Age: AgeField{gensupport.NewField[int32](declaration, "age")}, - Attrs: AttrsField{gensupport.NewField[map[string]any](declaration, "attrs")}, - Notes: NotesField{gensupport.NewField[string](declaration, "notes")}, + Name: NameField{gensupport.NewField[string](declaration, "name")}, } type EmailField struct { field gensupport.Field[string] } -func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextSearch, error) { +func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewTextSearch(out), err + return eql.NewTextEq(out), err } -func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextSearchQuery, error) { +func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewTextSearchQuery(out), err + return eql.NewTextEqQuery(out), err } -type AgeField struct { - field gensupport.Field[int32] +type NameField struct { + field gensupport.Field[string] } -func (f AgeField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v int32) (eql.IntegerOrd, error) { +func (f NameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewIntegerOrd(out), err + return eql.NewTextEq(out), err } -func (f AgeField) Query(ctx context.Context, c *stackencrypt.Cipher, v int32) (eql.IntegerOrdQuery, error) { +func (f NameField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewIntegerOrdQuery(out), err -} - -type AttrsField struct { - field gensupport.Field[map[string]any] -} - -func (f AttrsField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v map[string]any) (eql.JSON, error) { - out, err := f.field.Encrypt(ctx, c, v) - return eql.NewJSON(out), err -} - -func (f AttrsField) Contains(ctx context.Context, c *stackencrypt.Cipher, v map[string]any) (eql.JSONQuery, error) { - out, err := f.field.Contains(ctx, c, v) - return eql.JSONQuery(out.JSON), err -} - -type NotesField struct { - field gensupport.Field[string] -} - -func (f NotesField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.Text, error) { - out, err := f.field.Encrypt(ctx, c, v) - return eql.NewText(out), err + return eql.NewTextEqQuery(out), err } From 0e73334b797ef5a14396783e0bb218df43eed910 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 18:13:36 +1100 Subject: [PATCH 14/22] docs(plans): the verified status of each EQL type; eql is a package The last commit said separate columns support an ordering or a match search today, and said the EQL types wait on ordering and match terms in EQL's form. Neither was checked against the engine. Both are now. What the source says: - stack-encrypt derives four terms today: equality, match, CLLW ORE and CLLW OPE (packages/stack-encrypt/src/sem/mod.rs). The existing Go package returns all four (stackencrypt/term.go). So separate columns do work today for those searches. - EQL stores a block ORE term (ore_block_256) for OrdOre and SearchOre. The engine's ORE term is CLLW ORE, a different algorithm. So those two EQL types need more than wiring. - EQL's OPE term is CLLW OPE, the family the engine already derives, and its match term is a bloom filter, which the engine also derives. No EQL type is built from them yet: eql-codegen lists one domain that stack-encrypt can encrypt into, text equality. The plan now gives the reason for each group of EQL types, in place of one general sentence. The plan also states that stackencrypt/eql is a package in the Go module. That answers the open question about a package or a separate module, so the question is removed. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index a88da0f9f..7590ad40b 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -611,6 +611,7 @@ A library that maps one struct field to one column needs a model for this layout An EQL type is the Go type of one EQL column. `eql-codegen` writes the package `stackencrypt/eql` from the EQL catalog. +It is a package in the Go module, and not a module of its own. The same catalog gives the Rust and TypeScript types, so a type has one name in every language, such as `TextEq`. The JSON type is the exception: its Go name is `JSON`. @@ -635,12 +636,18 @@ Each type has a query type, with `Query` after its name: `TextEqQuery`. The value of `encrypt_into` is the Go type name. The engine produces one EQL type today: `TextEq`. -The other types wait for two pieces of work in the engine: +The other types wait for work in the engine: -- how the number, date, boolean and JSON families encode a plaintext; -- ordering and match terms in the form that EQL stores. +- **Every family but `Text`:** how the family encodes a plaintext is not specified. +- **`Match`, `Ord`, `OrdOpe` and `Search`:** the engine derives match and OPE terms, but no EQL type is built from them yet. +- **`OrdOre` and `SearchOre`:** EQL stores a block ORE term, and the engine derives a CLLW ORE term. + The two are different algorithms. +- **`JSON`:** the JSON index is a new operation in the engine. `stashgen` refuses an EQL type that the engine cannot produce. + +Separate columns work today for four terms: equality, match, ORE and OPE. +The engine derives all four, and the existing Go package returns them. For a field that needs an ordering or a match search today, use separate columns. ### What stashgen writes @@ -1179,6 +1186,3 @@ Then: - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. -- **The Go EQL package (`eqlv3` in the first draft) as a package or a - separate `go.mod`**: a package as read; correct if a separate module was - meant. From 11a174c03815a6ad0641678e518d6a695bb61988 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 18:17:42 +1100 Subject: [PATCH 15/22] docs(plans): name the Go package encrypt The Go SDK's package was stackencrypt. A caller writes the package name at every use, and the generated code names it in every signature: stackencrypt.Cipher, stackencrypt.Batch. The first half repeats what the import path already says, github.com/cipherstash/stack. The package is now encrypt, with its EQL types at encrypt/eql and the support for generated code at encrypt/gensupport. The plan lists the old name under Removed, so a reader of the existing module can match the two. The examples pass go vet against the stub, the seven mutation checks give the same results, and the sqlc package is regenerated for the new import path. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 22 ++++---- .../accounts/account.go | 4 +- .../accounts/account_stash.go | 20 ++++---- .../contacts/contacts.go | 20 ++++---- .../contacts/contactstash_stash.go | 50 +++++++++---------- .../documents/document_stash.go | 14 +++--- .../documents/documents.go | 8 +-- .../individuals/individual_stash.go | 32 ++++++------ .../individualstore/store.go | 6 +-- .../internal/userdb/models.go | 2 +- .../internal/userdb/query.sql.go | 2 +- docs/plans/2026-10-04-plan-builder/main.go | 8 +-- .../2026-10-04-plan-builder/policy/policy.go | 10 ++-- .../2026-10-04-plan-builder/sqlc/sqlc.yaml | 4 +- .../users/gormstore.go | 6 +-- .../users/sqlcstore.go | 10 ++-- .../2026-10-04-plan-builder/users/sqlstore.go | 8 +-- .../users/user_stash.go | 28 +++++------ 18 files changed, 128 insertions(+), 126 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 7590ad40b..794fc79e7 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -506,6 +506,7 @@ the type. The Go SDK is what a Go program uses: struct tags, a generator, and the code the generator writes. The binding is the WASI interface between that code and the Rust engine. The SDK follows [the language SDK design principles](../sdk-design-principles.md). +Its package is `encrypt`, at `languages/golang/encrypt`. A struct's `stash` tags declare how each field is encrypted. A generator, `stashgen`, writes the encrypted type and its functions from the tags. @@ -521,7 +522,7 @@ type User struct { Name string `stash:"name,encrypt_into=TextEq"` } -cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")) +cipher := client.Keyset(encrypt.KeysetName("tenant-42")) encrypted, err := users.Encrypt(ctx, cipher, people) // []users.EncryptedUser, one ZeroKMS request people, err := users.Decrypt(ctx, cipher, encrypted) // []users.User @@ -610,7 +611,7 @@ A library that maps one struct field to one column needs a model for this layout ### EQL types An EQL type is the Go type of one EQL column. -`eql-codegen` writes the package `stackencrypt/eql` from the EQL catalog. +`eql-codegen` writes the package `encrypt/eql` from the EQL catalog. It is a package in the Go module, and not a module of its own. The same catalog gives the Rust and TypeScript types, so a type has one name in every language, such as `TextEq`. The JSON type is the exception: its Go name is `JSON`. @@ -678,14 +679,14 @@ See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_ | Call | Returns | |---|---| -| `users.Encrypt(ctx, c *stackencrypt.Cipher, vs []User)` | `[]EncryptedUser` | -| `users.Decrypt(ctx, d stackencrypt.Decrypter, es []EncryptedUser)` | `[]User` | +| `users.Encrypt(ctx, c *encrypt.Cipher, vs []User)` | `[]EncryptedUser` | +| `users.Decrypt(ctx, d encrypt.Decrypter, es []EncryptedUser)` | `[]User` | | `users.Fields.Email.Encrypt(ctx, c, v string)` | the field's generated type, for an update of one column | | `users.Fields.Email.Query(ctx, c, v string)` | an EQL query value, for a field with `encrypt_into` | | `users.Fields.Email.Equality`, `.Match`, `.Ore`, `.Ope` | one term, for a field with `index=` | -| `users.EncryptInto(dst *[]EncryptedUser, vs []User)` | a `stackencrypt.Operation`, for a batch | -| `users.DecryptInto(dst *[]User, es []EncryptedUser)` | a `stackencrypt.Operation`, for a batch | -| `stackencrypt.Batch(ctx, c, ops ...Operation)` | nothing; it writes each result to its `dst` | +| `users.EncryptInto(dst *[]EncryptedUser, vs []User)` | a `encrypt.Operation`, for a batch | +| `users.DecryptInto(dst *[]User, es []EncryptedUser)` | a `encrypt.Operation`, for a batch | +| `encrypt.Batch(ctx, c, ops ...Operation)` | nothing; it writes each result to its `dst` | Every call also returns an `error`. `Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. @@ -703,7 +704,7 @@ The compiler checks that the variable has the result's type. var encryptedUsers []users.EncryptedUser var encryptedContacts []contacts.EncryptedContact -err := stackencrypt.Batch(ctx, cipher, +err := encrypt.Batch(ctx, cipher, users.EncryptInto(&encryptedUsers, people), contacts.EncryptInto(&encryptedContacts, list), ) @@ -718,7 +719,7 @@ A `*Cipher` also refuses a value from another keyset, with `ErrForeignKeyset`. The cipher holds what changes from one caller to the next: the keyset, and any extension of the context. ```go -cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")).Extend("tenant-42") +cipher := client.Keyset(encrypt.KeysetName("tenant-42")).Extend("tenant-42") ``` `Extend` returns a cipher that extends the context of every field, in every call through it. @@ -922,7 +923,7 @@ It does not send the value of a passthrough field, and it copies that value to t Generated code assembles an EQL value from the engine's ciphertext and terms. A cross-language fixture guards those bytes: encode in Rust, decode and encode again in Go, and compare. -The package `stackencrypt/gensupport` holds what only generated code calls. +The package `encrypt/gensupport` holds what only generated code calls. No function in it panics. Each generated file names a constant, such as `gensupport.GeneratedVersion1`, that only a library of an agreeing version declares. So a file from another version does not compile. @@ -963,6 +964,7 @@ The SDK gives the values for a search, and the program writes the SQL that uses The existing Go package has never been released, so these are removed, not deprecated: +- The package name `stackencrypt`: the package is `encrypt`. - `Cipher.Encrypt`, `Cipher.Decrypt` and `Client.Decrypt`: an `opaque` struct replaces them. - `EncryptElement` and `DecryptElement`. - `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account.go b/docs/plans/2026-10-04-plan-builder/accounts/account.go index 412235a74..748319a79 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account.go @@ -5,7 +5,7 @@ package accounts import ( "context" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" "gorm.io/gorm" ) @@ -30,7 +30,7 @@ type Account struct { func (EncryptedAccount) TableName() string { return "accounts" } -func Create(ctx context.Context, db *gorm.DB, cipher *stackencrypt.Cipher, accounts []Account) error { +func Create(ctx context.Context, db *gorm.DB, cipher *encrypt.Cipher, accounts []Account) error { encrypted, err := Encrypt(ctx, cipher, accounts) if err != nil { return err diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go index c7523f3f5..6adbfe852 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -6,9 +6,9 @@ import ( "context" "log/slog" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" "gorm.io/gorm" ) @@ -56,7 +56,7 @@ var declaration = gensupport.Declare("accounts"). Passthrough("created_at"). Passthrough("updated_at"). Passthrough("deleted_at"). - EncryptIndex("email", stackencrypt.Equality). + EncryptIndex("email", encrypt.Equality). Omit("token") var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ @@ -81,19 +81,19 @@ var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ }, }) -func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, accounts []Account) ([]EncryptedAccount, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, accounts []Account) ([]EncryptedAccount, error) { return codec.Encrypt(ctx, cipher, accounts) } -func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedAccount) ([]Account, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedAccount) ([]Account, error) { return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedAccount, accounts []Account) stackencrypt.Operation { +func EncryptInto(dst *[]EncryptedAccount, accounts []Account) encrypt.Operation { return codec.EncryptInto(dst, accounts) } -func DecryptInto(dst *[]Account, encrypted []EncryptedAccount) stackencrypt.Operation { +func DecryptInto(dst *[]Account, encrypted []EncryptedAccount) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } @@ -107,12 +107,12 @@ type EmailField struct { field gensupport.Field[string] } -func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { +func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.NewTextEq(out), err } -func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { +func (f EmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) return eql.NewTextEqQuery(out), err } diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go index 2d6fa7df1..0bf3717d3 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contacts.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contacts.go @@ -7,7 +7,7 @@ import ( "database/sql" "example.com/app/crm" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" "gorm.io/gorm" ) @@ -27,18 +27,18 @@ type contactStash struct { // ContactRow is a model: one field for each column. Each tag names the output // the field holds. type ContactRow struct { - ID int64 `stash:"id"` - Email stackencrypt.Ciphertext `stash:"email"` - EmailEq stackencrypt.EqualityTerm `stash:"email,equality"` - EmailMatch stackencrypt.MatchTerm `stash:"email,match"` - PhoneNumber stackencrypt.Ciphertext `stash:"phone_number"` - PhoneNumberEq stackencrypt.EqualityTerm `stash:"phone_number,equality"` + ID int64 `stash:"id"` + Email encrypt.Ciphertext `stash:"email"` + EmailEq encrypt.EqualityTerm `stash:"email,equality"` + EmailMatch encrypt.MatchTerm `stash:"email,match"` + PhoneNumber encrypt.Ciphertext `stash:"phone_number"` + PhoneNumberEq encrypt.EqualityTerm `stash:"phone_number,equality"` } func (ContactRow) TableName() string { return "contacts" } // Create writes with database/sql and passes each output itself. -func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, list []crm.Contact) error { +func Create(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, list []crm.Contact) error { encrypted, err := Encrypt(ctx, cipher, list) if err != nil { return err @@ -57,7 +57,7 @@ func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, list [ } // CreateWithGORM writes the model, which GORM maps one field to one column. -func CreateWithGORM(ctx context.Context, db *gorm.DB, cipher *stackencrypt.Cipher, list []crm.Contact) error { +func CreateWithGORM(ctx context.Context, db *gorm.DB, cipher *encrypt.Cipher, list []crm.Contact) error { rows, err := EncryptRows(ctx, cipher, list) if err != nil { return err @@ -65,7 +65,7 @@ func CreateWithGORM(ctx context.Context, db *gorm.DB, cipher *stackencrypt.Ciphe return db.WithContext(ctx).Create(&rows).Error } -func IDByPhone(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, phone string) (int64, error) { +func IDByPhone(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, phone string) (int64, error) { term, err := Fields.PhoneNumber.Equality(ctx, cipher, phone) if err != nil { return 0, err diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index 8fe6bf37c..def357659 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -7,8 +7,8 @@ import ( "log/slog" "example.com/app/crm" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" ) // Stops compiling when the library does not accept this version of generated file. @@ -24,14 +24,14 @@ type EncryptedContact struct { } type EncryptedContactEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Match stackencrypt.MatchTerm + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm + Match encrypt.MatchTerm } type EncryptedContactPhoneNumber struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm } func (e EncryptedContact) String() string { @@ -54,8 +54,8 @@ type contactShape struct { var declaration = gensupport.Declare("contacts"). Passthrough("id"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("phone_number", stackencrypt.Equality). + EncryptIndex("email", encrypt.Equality, encrypt.Match()). + EncryptIndex("phone_number", encrypt.Equality). Omit("internal") var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ @@ -92,19 +92,19 @@ var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ }, }) -func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, contacts []crm.Contact) ([]EncryptedContact, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, contacts []crm.Contact) ([]EncryptedContact, error) { return codec.Encrypt(ctx, cipher, contacts) } -func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedContact) ([]crm.Contact, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedContact) ([]crm.Contact, error) { return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedContact, contacts []crm.Contact) stackencrypt.Operation { +func EncryptInto(dst *[]EncryptedContact, contacts []crm.Contact) encrypt.Operation { return codec.EncryptInto(dst, contacts) } -func DecryptInto(dst *[]crm.Contact, encrypted []EncryptedContact) stackencrypt.Operation { +func DecryptInto(dst *[]crm.Contact, encrypted []EncryptedContact) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } @@ -120,7 +120,7 @@ type EmailField struct { field gensupport.Field[string] } -func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedContactEmail, error) { +func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedContactEmail, error) { out, err := f.field.Encrypt(ctx, c, v) if err != nil { return EncryptedContactEmail{}, err @@ -128,11 +128,11 @@ func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v strin return EncryptedContactEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil } -func (f EmailField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { +func (f EmailField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { return f.field.Equality(ctx, c, v) } -func (f EmailField) Match(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.MatchTerm, error) { +func (f EmailField) Match(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.MatchTerm, error) { return f.field.Match(ctx, c, v) } @@ -140,7 +140,7 @@ type PhoneNumberField struct { field gensupport.Field[string] } -func (f PhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedContactPhoneNumber, error) { +func (f PhoneNumberField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedContactPhoneNumber, error) { out, err := f.field.Encrypt(ctx, c, v) if err != nil { return EncryptedContactPhoneNumber{}, err @@ -148,7 +148,7 @@ func (f PhoneNumberField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v return EncryptedContactPhoneNumber{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil } -func (f PhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { +func (f PhoneNumberField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { return f.field.Equality(ctx, c, v) } @@ -156,11 +156,11 @@ func (f PhoneNumberField) Equality(ctx context.Context, c *stackencrypt.Cipher, // two have the same fields, with the same types, in the same order. type rowsShape struct { ID int64 - Email stackencrypt.Ciphertext - EmailEq stackencrypt.EqualityTerm - EmailMatch stackencrypt.MatchTerm - PhoneNumber stackencrypt.Ciphertext - PhoneNumberEq stackencrypt.EqualityTerm + Email encrypt.Ciphertext + EmailEq encrypt.EqualityTerm + EmailMatch encrypt.MatchTerm + PhoneNumber encrypt.Ciphertext + PhoneNumberEq encrypt.EqualityTerm } var rowsCodec = gensupport.Records(codec, @@ -184,10 +184,10 @@ var rowsCodec = gensupport.Records(codec, }, ) -func EncryptRows(ctx context.Context, cipher *stackencrypt.Cipher, contacts []crm.Contact) ([]ContactRow, error) { +func EncryptRows(ctx context.Context, cipher *encrypt.Cipher, contacts []crm.Contact) ([]ContactRow, error) { return rowsCodec.Encrypt(ctx, cipher, contacts) } -func DecryptRows(ctx context.Context, d stackencrypt.Decrypter, rows []ContactRow) ([]crm.Contact, error) { +func DecryptRows(ctx context.Context, d encrypt.Decrypter, rows []ContactRow) ([]crm.Contact, error) { return rowsCodec.Decrypt(ctx, d, rows) } diff --git a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go index c523d951b..c773dc583 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go +++ b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go @@ -6,8 +6,8 @@ import ( "context" "log/slog" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" ) // Stops compiling when the library does not accept this version of generated file. @@ -17,7 +17,7 @@ const _ = gensupport.GeneratedVersion1 // LogValue method. Write them, or run stashgen with -redact. type EncryptedDocument struct { - Sealed stackencrypt.Ciphertext + Sealed encrypt.Ciphertext } func (e EncryptedDocument) String() string { @@ -72,18 +72,18 @@ var codec = gensupport.New(gensupport.Generated[Document, EncryptedDocument]{ }, }) -func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, documents []Document) ([]EncryptedDocument, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, documents []Document) ([]EncryptedDocument, error) { return codec.Encrypt(ctx, cipher, documents) } -func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedDocument) ([]Document, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedDocument) ([]Document, error) { return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedDocument, documents []Document) stackencrypt.Operation { +func EncryptInto(dst *[]EncryptedDocument, documents []Document) encrypt.Operation { return codec.EncryptInto(dst, documents) } -func DecryptInto(dst *[]Document, encrypted []EncryptedDocument) stackencrypt.Operation { +func DecryptInto(dst *[]Document, encrypted []EncryptedDocument) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } diff --git a/docs/plans/2026-10-04-plan-builder/documents/documents.go b/docs/plans/2026-10-04-plan-builder/documents/documents.go index 8065d0d1e..09edf3125 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/documents.go +++ b/docs/plans/2026-10-04-plan-builder/documents/documents.go @@ -6,7 +6,7 @@ import ( "context" "database/sql" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" ) //go:generate go tool stashgen -type Document @@ -18,7 +18,7 @@ type Document struct { Tags []string } -func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64, doc Document) error { +func Save(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, id int64, doc Document) error { encrypted, err := Encrypt(ctx, cipher, []Document{doc}) if err != nil { return err @@ -28,9 +28,9 @@ func Save(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, id int64 } // Load decrypts with the client, which opens each value under the keyset that -// sealed it. A *stackencrypt.Cipher would also refuse a value from another +// sealed it. A *encrypt.Cipher would also refuse a value from another // keyset. -func Load(ctx context.Context, db *sql.DB, client *stackencrypt.Client, id int64) (Document, error) { +func Load(ctx context.Context, db *sql.DB, client *encrypt.Client, id int64) (Document, error) { var encrypted EncryptedDocument if err := db.QueryRowContext(ctx, `SELECT body FROM documents WHERE id = $1`, id).Scan(&encrypted.Sealed); err != nil { return Document{}, err diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index 3675f4d32..ba652ebcf 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -8,8 +8,8 @@ import ( "context" "log/slog" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" ) // Stops compiling when the library does not accept this version of generated file. @@ -27,18 +27,18 @@ type EncryptedIndividual struct { } type EncryptedIndividualName struct { - Ciphertext stackencrypt.Ciphertext + Ciphertext encrypt.Ciphertext } type EncryptedIndividualEmail struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm - Match stackencrypt.MatchTerm + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm + Match encrypt.MatchTerm } type EncryptedIndividualMedicareNo struct { - Ciphertext stackencrypt.Ciphertext - Equality stackencrypt.EqualityTerm + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm } func (e EncryptedIndividual) String() string { @@ -63,8 +63,8 @@ type individualShape struct { var declaration = gensupport.Declare("individuals"). Passthrough("id"). Encrypt("name"). - EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match()). - EncryptIndex("medicare_number", stackencrypt.Equality). + EncryptIndex("email", encrypt.Equality, encrypt.Match()). + EncryptIndex("medicare_number", encrypt.Equality). Passthrough("nickname") var codec = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual]{ @@ -107,19 +107,19 @@ var codec = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual] }, }) -func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, individuals []Individual) ([]EncryptedIndividual, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, individuals []Individual) ([]EncryptedIndividual, error) { return codec.Encrypt(ctx, cipher, individuals) } -func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedIndividual) ([]Individual, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedIndividual) ([]Individual, error) { return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedIndividual, individuals []Individual) stackencrypt.Operation { +func EncryptInto(dst *[]EncryptedIndividual, individuals []Individual) encrypt.Operation { return codec.EncryptInto(dst, individuals) } -func DecryptInto(dst *[]Individual, encrypted []EncryptedIndividual) stackencrypt.Operation { +func DecryptInto(dst *[]Individual, encrypted []EncryptedIndividual) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } @@ -133,7 +133,7 @@ type MedicareNoField struct { field gensupport.Field[string] } -func (f MedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (EncryptedIndividualMedicareNo, error) { +func (f MedicareNoField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedIndividualMedicareNo, error) { out, err := f.field.Encrypt(ctx, c, v) if err != nil { return EncryptedIndividualMedicareNo{}, err @@ -141,6 +141,6 @@ func (f MedicareNoField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v return EncryptedIndividualMedicareNo{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil } -func (f MedicareNoField) Equality(ctx context.Context, c *stackencrypt.Cipher, v string) (stackencrypt.EqualityTerm, error) { +func (f MedicareNoField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { return f.field.Equality(ctx, c, v) } diff --git a/docs/plans/2026-10-04-plan-builder/individualstore/store.go b/docs/plans/2026-10-04-plan-builder/individualstore/store.go index 0f2665d66..10bb48bc0 100644 --- a/docs/plans/2026-10-04-plan-builder/individualstore/store.go +++ b/docs/plans/2026-10-04-plan-builder/individualstore/store.go @@ -6,10 +6,10 @@ import ( "database/sql" "example.com/app/individuals" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" ) -func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person individuals.Individual) error { +func Create(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, person individuals.Individual) error { encrypted, err := individuals.Encrypt(ctx, cipher, []individuals.Individual{person}) if err != nil { return err @@ -23,7 +23,7 @@ func Create(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, person return err } -func IDByMedicare(ctx context.Context, db *sql.DB, cipher *stackencrypt.Cipher, medicareNo string) (int64, error) { +func IDByMedicare(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, medicareNo string) (int64, error) { term, err := individuals.Fields.MedicareNo.Equality(ctx, cipher, medicareNo) if err != nil { return 0, err diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go index 179dfd37b..0aba02e52 100644 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/models.go @@ -5,7 +5,7 @@ package userdb import ( - "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" ) type User struct { diff --git a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go index c5e4bb45c..9ad43ffb9 100644 --- a/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go +++ b/docs/plans/2026-10-04-plan-builder/internal/userdb/query.sql.go @@ -8,7 +8,7 @@ package userdb import ( "context" - "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" ) const createUser = `-- name: CreateUser :exec diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go index 98c848d5d..5397a72c1 100644 --- a/docs/plans/2026-10-04-plan-builder/main.go +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -13,7 +13,7 @@ import ( "example.com/app/crm" "example.com/app/documents" "example.com/app/users" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" ) func main() { @@ -23,7 +23,7 @@ func main() { } func run(ctx context.Context) error { - client, err := stackencrypt.NewClient(ctx, stackencrypt.WithCredentials(stackencrypt.AutoCredentials())) + client, err := encrypt.NewClient(ctx, encrypt.WithCredentials(encrypt.AutoCredentials())) if err != nil { return err } @@ -37,7 +37,7 @@ func run(ctx context.Context) error { // One cipher for each tenant: its keyset, and its part of every field's // context. Every call through this cipher carries both. - cipher := client.Keyset(stackencrypt.KeysetName("tenant-42")).Extend("tenant-42") + cipher := client.Keyset(encrypt.KeysetName("tenant-42")).Extend("tenant-42") store := users.NewSQLStore(db) alice := users.User{ID: 1, Email: "alice@example.com", Name: "Alice Ng", Internal: "never stored"} @@ -62,7 +62,7 @@ func run(ctx context.Context) error { list := []crm.Contact{{ID: 9, Email: "dan@example.com", PhoneNumber: "+61 400 000 000"}} var encryptedUsers []users.EncryptedUser var encryptedContacts []contacts.EncryptedContact - err = stackencrypt.Batch(ctx, cipher, + err = encrypt.Batch(ctx, cipher, users.EncryptInto(&encryptedUsers, newHires), contacts.EncryptInto(&encryptedContacts, list), ) diff --git a/docs/plans/2026-10-04-plan-builder/policy/policy.go b/docs/plans/2026-10-04-plan-builder/policy/policy.go index 85a66c84e..7eb44f76f 100644 --- a/docs/plans/2026-10-04-plan-builder/policy/policy.go +++ b/docs/plans/2026-10-04-plan-builder/policy/policy.go @@ -4,8 +4,8 @@ package policy import ( "example.com/app/individuals" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/plan" ) var category = plan.Key("fides.data_categories") @@ -33,14 +33,14 @@ var Source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { }) var Base = plan.FirstOf( - plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), - plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match()))), + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(encrypt.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(encrypt.Equality, encrypt.Match()))), plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), ) var Individuals = plan.ForMessage(individuals.Individual{}, plan.Table("individuals"), plan.FirstOf( - plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(encrypt.Equality)), plan.Column("medicare_number")), ).OrElse(Base), ) diff --git a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml index 771802016..7131b7066 100644 --- a/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml +++ b/docs/plans/2026-10-04-plan-builder/sqlc/sqlc.yaml @@ -11,6 +11,6 @@ sql: out: ../internal/userdb overrides: - db_type: "public.eql_v3_text_eq" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextEq } + go_type: { import: github.com/cipherstash/stack/languages/golang/encrypt/eql, type: TextEq } - db_type: "eql_v3.query_text_eq" - go_type: { import: github.com/cipherstash/stack/languages/golang/stackencrypt/eql, type: TextEqQuery } + go_type: { import: github.com/cipherstash/stack/languages/golang/encrypt/eql, type: TextEqQuery } diff --git a/docs/plans/2026-10-04-plan-builder/users/gormstore.go b/docs/plans/2026-10-04-plan-builder/users/gormstore.go index 50c3bbf25..680dd57cd 100644 --- a/docs/plans/2026-10-04-plan-builder/users/gormstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/gormstore.go @@ -3,7 +3,7 @@ package users import ( "context" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" "gorm.io/gorm" ) @@ -22,7 +22,7 @@ func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} } -func (s *GormStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, people ...User) error { +func (s *GormStore) Create(ctx context.Context, cipher *encrypt.Cipher, people ...User) error { encrypted, err := Encrypt(ctx, cipher, people) if err != nil { return err @@ -30,7 +30,7 @@ func (s *GormStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, peo return s.db.WithContext(ctx).CreateInBatches(encrypted, 500).Error } -func (s *GormStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { +func (s *GormStore) FindByEmail(ctx context.Context, cipher *encrypt.Cipher, email string) ([]User, error) { query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go index f8f44a25b..3b78fd03b 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlcstore.go @@ -6,7 +6,7 @@ import ( "errors" "example.com/app/internal/userdb" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" ) var ErrNotFound = errors.New("users: not found") @@ -24,7 +24,7 @@ func NewSQLCStore(db *sql.DB) *SQLCStore { return &SQLCStore{db: db, queries: userdb.New(db)} } -func (s *SQLCStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, people []User) error { +func (s *SQLCStore) Import(ctx context.Context, cipher *encrypt.Cipher, people []User) error { encrypted, err := Encrypt(ctx, cipher, people) if err != nil { return err @@ -45,7 +45,7 @@ func (s *SQLCStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, peo return tx.Commit() } -func (s *SQLCStore) Get(ctx context.Context, cipher *stackencrypt.Cipher, id int64) (User, error) { +func (s *SQLCStore) Get(ctx context.Context, cipher *encrypt.Cipher, id int64) (User, error) { row, err := s.queries.GetUser(ctx, id) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrNotFound @@ -60,7 +60,7 @@ func (s *SQLCStore) Get(ctx context.Context, cipher *stackencrypt.Cipher, id int return users[0], nil } -func (s *SQLCStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { +func (s *SQLCStore) FindByEmail(ctx context.Context, cipher *encrypt.Cipher, email string) ([]User, error) { query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err @@ -78,7 +78,7 @@ func (s *SQLCStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher // ChangeEmail rewrites one field. One EQL column holds the ciphertext and // its term, so they cannot go out of step. -func (s *SQLCStore) ChangeEmail(ctx context.Context, cipher *stackencrypt.Cipher, id int64, email string) error { +func (s *SQLCStore) ChangeEmail(ctx context.Context, cipher *encrypt.Cipher, id int64, email string) error { sealed, err := Fields.Email.Encrypt(ctx, cipher, email) if err != nil { return err diff --git a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go index b00ab319f..0d0aa5803 100644 --- a/docs/plans/2026-10-04-plan-builder/users/sqlstore.go +++ b/docs/plans/2026-10-04-plan-builder/users/sqlstore.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" - "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/encrypt" ) const ( @@ -26,7 +26,7 @@ func NewSQLStore(db *sql.DB) *SQLStore { // Import encrypts every user in one ZeroKMS request, then inserts them in one // transaction. -func (s *SQLStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, people []User) error { +func (s *SQLStore) Import(ctx context.Context, cipher *encrypt.Cipher, people []User) error { encrypted, err := Encrypt(ctx, cipher, people) if err != nil { return fmt.Errorf("encrypt %d users: %w", len(people), err) @@ -52,13 +52,13 @@ func (s *SQLStore) Import(ctx context.Context, cipher *stackencrypt.Cipher, peop return tx.Commit() } -func (s *SQLStore) Create(ctx context.Context, cipher *stackencrypt.Cipher, user User) error { +func (s *SQLStore) Create(ctx context.Context, cipher *encrypt.Cipher, user User) error { return s.Import(ctx, cipher, []User{user}) } // FindByEmail matches the whole address. Postgres compares the encrypted // column through EQL's = operator. -func (s *SQLStore) FindByEmail(ctx context.Context, cipher *stackencrypt.Cipher, email string) ([]User, error) { +func (s *SQLStore) FindByEmail(ctx context.Context, cipher *encrypt.Cipher, email string) ([]User, error) { query, err := Fields.Email.Query(ctx, cipher, email) if err != nil { return nil, err diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index 083e72c69..c959205df 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -6,9 +6,9 @@ import ( "context" "log/slog" - "github.com/cipherstash/stack/languages/golang/stackencrypt" - "github.com/cipherstash/stack/languages/golang/stackencrypt/eql" - "github.com/cipherstash/stack/languages/golang/stackencrypt/gensupport" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" ) // Stops compiling when the library does not accept this version of generated file. @@ -44,8 +44,8 @@ type userShape struct { var declaration = gensupport.Declare("users"). Passthrough("id"). - EncryptIndex("email", stackencrypt.Equality). - EncryptIndex("name", stackencrypt.Equality). + EncryptIndex("email", encrypt.Equality). + EncryptIndex("name", encrypt.Equality). Omit("internal") var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ @@ -80,22 +80,22 @@ var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ // Encrypt seals every user in one ZeroKMS request. The result has one element // for each user, in the same order. -func Encrypt(ctx context.Context, cipher *stackencrypt.Cipher, users []User) ([]EncryptedUser, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, users []User) ([]EncryptedUser, error) { return codec.Encrypt(ctx, cipher, users) } // Decrypt opens every value in one ZeroKMS request. -func Decrypt(ctx context.Context, d stackencrypt.Decrypter, encrypted []EncryptedUser) ([]User, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedUser) ([]User, error) { return codec.Decrypt(ctx, d, encrypted) } -// EncryptInto and DecryptInto describe the same work for stackencrypt.Batch, +// EncryptInto and DecryptInto describe the same work for encrypt.Batch, // which runs it and writes the result to dst. -func EncryptInto(dst *[]EncryptedUser, users []User) stackencrypt.Operation { +func EncryptInto(dst *[]EncryptedUser, users []User) encrypt.Operation { return codec.EncryptInto(dst, users) } -func DecryptInto(dst *[]User, encrypted []EncryptedUser) stackencrypt.Operation { +func DecryptInto(dst *[]User, encrypted []EncryptedUser) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } @@ -111,12 +111,12 @@ type EmailField struct { field gensupport.Field[string] } -func (f EmailField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { +func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.NewTextEq(out), err } -func (f EmailField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { +func (f EmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) return eql.NewTextEqQuery(out), err } @@ -125,12 +125,12 @@ type NameField struct { field gensupport.Field[string] } -func (f NameField) Encrypt(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEq, error) { +func (f NameField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) return eql.NewTextEq(out), err } -func (f NameField) Query(ctx context.Context, c *stackencrypt.Cipher, v string) (eql.TextEqQuery, error) { +func (f NameField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) return eql.NewTextEqQuery(out), err } From 40d93cc97f0753a7e875b4bf13722f293f54bdd1 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 18:31:52 +1100 Subject: [PATCH 16/22] docs(plans): name the Go credentials package auth The sibling of the encrypt package was stackauth. It is renamed auth for the same reason: the import path already says cipherstash/stack, and a caller writes the package name at every use. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 794fc79e7..0a56ca713 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -507,6 +507,7 @@ The Go SDK is what a Go program uses: struct tags, a generator, and the code the The binding is the WASI interface between that code and the Rust engine. The SDK follows [the language SDK design principles](../sdk-design-principles.md). Its package is `encrypt`, at `languages/golang/encrypt`. +The package for credentials and the developer profile is `auth`, at `languages/golang/auth`. A struct's `stash` tags declare how each field is encrypted. A generator, `stashgen`, writes the encrypted type and its functions from the tags. @@ -964,7 +965,7 @@ The SDK gives the values for a search, and the program writes the SQL that uses The existing Go package has never been released, so these are removed, not deprecated: -- The package name `stackencrypt`: the package is `encrypt`. +- The package names `stackencrypt` and `stackauth`: the packages are `encrypt` and `auth`. - `Cipher.Encrypt`, `Cipher.Decrypt` and `Client.Decrypt`: an `opaque` struct replaces them. - `EncryptElement` and `DecryptElement`. - `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. From 445c0a86e7176cb6243c1d0de7c5bb16bafb8cef Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Mon, 5 Oct 2026 19:28:58 +1100 Subject: [PATCH 17/22] docs(plans): keep the policy, for types a schema generates Re-evaluates the policy package against the SDK design principles and keeps it, with five changes. The plan states the design; the reasons are here. Why keep it. Read from the code alone, the package had no user: its only importers were its own tests, and no real source of facts existed. On that evidence the recommendation was to remove it. There is a real user. Their types are generated from protobuf, so they cannot carry tags, and their schema already holds a data category for each field. A declaring struct for each message would mean writing every message a second time by hand. So the policy serves a need tags cannot meet, which is the first of the four tests for a second way in. Why change it. A real user does not fix the ways the package broke the principles: - A quiet path to plaintext. A field with no annotation that no rule named was "left out of the plan and stored as it is", with no error. With tags the same field stops the generator. Every field of a message now needs a decision, and a catch-all exists only when its author writes one. - The word "plan". The package is encrypt/policy. Its decisions are the tag verbs (Encrypt, EncryptIndex, Index, EncryptInto, Passthrough, Omit), so a rule and a tag say the same thing in the same words. Plaintext becomes Passthrough, Column becomes Name, Table becomes Context. - A source of facts. A policy with no source does nothing. The plan names encrypt/policy/protosource, which reads a protobuf descriptor and its field options. - The build constraint. The last design put the generated file in the type's own package, so the generate program imported a file it had written, and a stale file stopped it from building. That needed a build tag and a rule about where other code could live. Generated functions do not need to live beside the type. For this user the type is in a package protoc writes, so the output goes in a package of their own, and the problem is gone. - Tags or a policy, with a stated line between them: tags for a type you write, a policy for a type a schema generates. Kept from the package: Identity, which holds a field's context when its column gets a new name. Tags have no word for that yet, and the open question on changing a declaration now says so. The example is rebuilt on real protobuf code. proto/ holds a message whose fields carry a data_categories option, and buf v1.50.0 with protoc-gen-go wrote internal/pb. The generated file for it compiles against that code, which confirms two statements in the plan: Go cannot convert a protobuf message to a copy of its fields, and a field added to the message does not stop the build, so CI finds that change. Not run: the protobuf source and the rules themselves. Neither the source nor the generator exists. Every Go file passes `go vet ./...` against the uncommitted stub, and gofmt reports nothing. The amended text passes slipstream's checks with 0 findings, and every relative link resolves. Refs #1046 Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- .../0002-language-sdk-design-principles.md | 3 +- docs/plans/2026-10-04-plan-builder.md | 74 +++++-- docs/plans/2026-10-04-plan-builder/README.md | 17 +- .../cmd/genencrypt/main.go | 18 ++ .../cmd/genplans/main.go | 15 -- .../individuals/individual.go | 14 -- .../individuals/individual_stash.go | 115 ++++++----- .../individuals/store.go | 40 ++++ .../individualstore/store.go | 34 ---- .../internal/pb/classification.pb.go | 94 +++++++++ .../internal/pb/individual.pb.go | 188 ++++++++++++++++++ .../2026-10-04-plan-builder/policy/policy.go | 46 ----- .../proto/buf.gen.yaml | 5 + .../proto/classification.proto | 12 ++ .../proto/individual.proto | 15 ++ .../2026-10-04-plan-builder/rules/rules.go | 31 +++ docs/sdk-design-principles.md | 1 - 17 files changed, 538 insertions(+), 184 deletions(-) create mode 100644 docs/plans/2026-10-04-plan-builder/cmd/genencrypt/main.go delete mode 100644 docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go delete mode 100644 docs/plans/2026-10-04-plan-builder/individuals/individual.go create mode 100644 docs/plans/2026-10-04-plan-builder/individuals/store.go delete mode 100644 docs/plans/2026-10-04-plan-builder/individualstore/store.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/pb/classification.pb.go create mode 100644 docs/plans/2026-10-04-plan-builder/internal/pb/individual.pb.go delete mode 100644 docs/plans/2026-10-04-plan-builder/policy/policy.go create mode 100644 docs/plans/2026-10-04-plan-builder/proto/buf.gen.yaml create mode 100644 docs/plans/2026-10-04-plan-builder/proto/classification.proto create mode 100644 docs/plans/2026-10-04-plan-builder/proto/individual.proto create mode 100644 docs/plans/2026-10-04-plan-builder/rules/rules.go diff --git a/docs/adr/0002-language-sdk-design-principles.md b/docs/adr/0002-language-sdk-design-principles.md index 60fd0ce25..5165e9290 100644 --- a/docs/adr/0002-language-sdk-design-principles.md +++ b/docs/adr/0002-language-sdk-design-principles.md @@ -43,7 +43,6 @@ When two principles disagree, the document gives the order to apply them in. An SDK in a dynamic language ships rules for the language's type checkers and linters. - Each language SDK is its own design. The principles say what must match between them: the bytes, the words for engine behaviour, and fail-closed behaviour. -- Five questions are open, and the principles document lists them. - The largest is the approach of the Go policy package. +- Four questions are open, and the principles document lists them. ADR-0007 in `packages/stack-encrypt/docs/adr/` is the ground for the first principle: an SDK enters the engine through a declaration, and never through a second executor. diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 0a56ca713..1473910dc 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -863,15 +863,56 @@ The same input always gives the same file: fields keep their declared order, and ### Declarations from a policy -A policy decides what to encrypt from the data categories that a schema gives each field. -The policy is Go code, so a program must run it. -That program is a generate program that you own, and `go generate` runs it: +Some types cannot carry tags, and their schema already says what each field is. +A protobuf message is such a type: `protoc-gen-go` writes the struct, and a field option holds the field's data categories. +For those types, rules decide how each field is encrypted, and `stashgen` writes the same generated file from the rules. + +Tags are the way to declare a type that you write. +A policy is for a type that a schema generates. + +```go +var category = policy.Key("classification.data_categories") + +var Base = policy.FirstOf( + policy.When(category.Under("user.government_id"), policy.EncryptInto("TextEq")), + policy.When(category.Under("user.contact.email"), policy.EncryptIndex(encrypt.Equality, encrypt.Match())), + policy.When(category.Under("user"), policy.Encrypt()), +) + +var Individuals = policy.ForMessage(&pb.Individual{}, policy.Context("individuals"), + policy.FirstOf( + policy.When(policy.Field("id"), policy.Passthrough()), + policy.When(policy.Field("nickname"), policy.Passthrough()), + ).OrElse(Base), +) +``` + +The package is `encrypt/policy`. +A rule has a matcher and a decision, and the first rule that matches a field decides it. +The decisions are the tag verbs: `Encrypt`, `EncryptIndex`, `Index`, `EncryptInto`, `Passthrough` and `Omit`. +`Fail` refuses a field, with a reason. + +Every field of the message needs a decision. +A field that no rule decides stops the generator, with the field's name and its annotations. +That is true for a field with no annotation too. +`Otherwise` decides every field that no earlier rule matched. +A policy has an `Otherwise` rule only when its author writes one. + +`Name` sets the field's name, which is the column name. +`Identity` keeps the field's context when its column gets a new name. +Data that was written before the change then still decrypts. + +A source gives the facts about each field: its name, its Go name, its kind and its annotations. +The package `encrypt/policy/protosource` reads them from a protobuf descriptor and its field options. + +A program that you own runs the rules, and `go generate` runs that program: ```go -//go:generate go run -tags stashgen ../cmd/genplans +//go:generate go run ../cmd/genencrypt func main() { - err := stashgen.Generate(policy.Source, policy.Individuals, stashgen.Output("individual_stash.go")) + err := stashgen.Generate(protosource.New(), rules.Individuals, + stashgen.Output("../individuals/individual_stash.go")) if err != nil { log.Fatal(err) } @@ -879,18 +920,13 @@ func main() { ``` `stashgen.Generate` is the generator as a library, at `languages/golang/stashgen`. -It runs the policy over the facts and writes the same file that the tags give. -The application never runs the policy. - -- A field that no rule decides stops `go generate`, with the field's name and its annotations. -- A change to the policy changes the generated file, so a reviewer reads what the change encrypts. -- A field that the policy stores as plaintext is a passthrough field of the generated type. +The application never runs the rules. +A change to the rules changes the generated file, so a reviewer reads what the change encrypts. -The generate program imports the package that holds the type. -So that package must build when the generated file is stale. -The generated file carries the build constraint `!stashgen`, and the generate program runs with `-tags stashgen`. -Code that uses the generated names goes in another package. -See [`policy/policy.go`](2026-10-04-plan-builder/policy/policy.go), [`cmd/genplans/main.go`](2026-10-04-plan-builder/cmd/genplans/main.go) and [`individualstore/store.go`](2026-10-04-plan-builder/individualstore/store.go). +The generated file goes in a package of your own, and not in the package of the generated type. +So the generate program does not import a file that it wrote. +The generated functions take and return pointers to the message: `Encrypt` takes a `[]*pb.Individual`. +See [`rules/rules.go`](2026-10-04-plan-builder/rules/rules.go), [`cmd/genencrypt/main.go`](2026-10-04-plan-builder/cmd/genencrypt/main.go) and [`individuals/store.go`](2026-10-04-plan-builder/individuals/store.go). ### When a mistake is found @@ -975,7 +1011,8 @@ The existing Go package has never been released, so these are removed, not depre - `TermKind`: `Index` replaces it, for generated code. - `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them. - `PlanFromTags`, and every other function that reads `stash` tags at run time. -- `plan.PlanFor` and `plan.MustPlanFor`: `stashgen.Generate` replaces them. +- The package `plan`: the package is `encrypt/policy`, and `stashgen.Generate` replaces `PlanFor` and `MustPlanFor`. +- `plan.Plaintext`, `plan.Column`, `plan.Table`, `plan.EQL` and `plan.Custom`: `Passthrough`, `Name`, `Context` and the tag verbs replace them. - `Context`, `NewContext`, `Label`, `NewLabel` and `ParseLabel`: the `context=` tag replaces them. - The rule that a zero `Plan` means the struct's tags. - `Sealed`, `SealedNone`, `SealedEmptyMap` and `SealedEmptySeq` as storage types: `Ciphertext` replaces them. @@ -1181,11 +1218,10 @@ Then: - **A change to a declaration over time.** This design covers one version of a declaration. Reading data that an older declaration wrote needs its own design. + A policy has `Identity` for a column with a new name, and tags have no word for it yet. - **How the generator gets the engine's rules.** The generator must refuse what the engine refuses, from one source of rules. Whether it calls the engine or reads rules the engine publishes is not decided. -- **The policy package.** - Its approach is under review, so "Declarations from a policy" can change. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index 1a913d190..9c0d2972e 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -18,10 +18,10 @@ The module path is `example.com/app`. | [`accounts/account.go`](accounts/account.go) | An embedded `gorm.Model`, another library's tags, an unexported field and `-redact` | | [`contacts/contacts.go`](contacts/contacts.go) | [`crm.Contact`](crm/contact.go), a type in another package, in separate columns, with a model | | [`documents/documents.go`](documents/documents.go) | An `opaque` struct, sealed as one value | -| [`policy/policy.go`](policy/policy.go) | A policy that decides what to encrypt from each field's data categories | -| [`cmd/genplans/main.go`](cmd/genplans/main.go) | The generate program that runs the policy | -| [`individuals/`](individuals/) | A type with no tags, and the file the policy gives | -| [`individualstore/store.go`](individualstore/store.go) | A store that uses that generated type | +| [`proto/`](proto/) | A protobuf message whose fields carry data categories, which generates [`internal/pb/`](internal/pb/) | +| [`rules/rules.go`](rules/rules.go) | Rules that decide what to encrypt from each field's data categories | +| [`cmd/genencrypt/main.go`](cmd/genencrypt/main.go) | The generate program that runs the rules | +| [`individuals/`](individuals/) | The file the rules give for the protobuf message, and a store that uses it | The `users` example uses `TextEq`, which is the one EQL type the engine produces today. @@ -30,7 +30,9 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces | Claim | Status | |---|---| | Every Go file type-checks | Run. `go vet ./...` passes against a stub of the SDK. The stub is not in this repository, and it has signatures only. | -| The generate program builds when a generated file is stale | Run. `go build -tags stashgen ./cmd/genplans` passes after a field is added to `Individual`. | +| The policy example compiles against real protobuf code | Run. buf v1.50.0 and `protoc-gen-go` wrote `internal/pb/`, and `go vet` passes. | +| A field added to the protobuf message does not stop the build | Run. It builds, as the plan says: CI finds that change. | +| The protobuf source reads the field options, and the rules run | Not run. Neither the source nor the generator exists. | | A change to a tagged struct, a model or a type in another package stops the build | Run. Each change fails `go build` with "cannot convert". | | sqlc's row struct converts to the generated type | Run. The conversions in `users/sqlcstore.go` compile against real sqlc output. | | A struct from another package with an unexported field cannot convert | Run, with `sync.Once`. | @@ -41,6 +43,11 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces | The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | | The steps in "Use the SDK" | Not run. Nobody has followed them. | +## Generate the protobuf package + +The protobuf package was generated with buf v1.50.0 and `protoc-gen-go`. +Run `buf generate` in `proto/` to generate it again. + ## Generate the sqlc package The sqlc package was generated with sqlc v1.31.1. diff --git a/docs/plans/2026-10-04-plan-builder/cmd/genencrypt/main.go b/docs/plans/2026-10-04-plan-builder/cmd/genencrypt/main.go new file mode 100644 index 000000000..bd19353e9 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/cmd/genencrypt/main.go @@ -0,0 +1,18 @@ +// Command genencrypt runs the rules at go generate time. +package main + +import ( + "log" + + "example.com/app/rules" + "github.com/cipherstash/stack/languages/golang/encrypt/policy/protosource" + "github.com/cipherstash/stack/languages/golang/stashgen" +) + +func main() { + err := stashgen.Generate(protosource.New(), rules.Individuals, + stashgen.Output("../individuals/individual_stash.go")) + if err != nil { + log.Fatal(err) + } +} diff --git a/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go b/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go deleted file mode 100644 index 904b37941..000000000 --- a/docs/plans/2026-10-04-plan-builder/cmd/genplans/main.go +++ /dev/null @@ -1,15 +0,0 @@ -// Command genplans runs the policy at go generate time. -package main - -import ( - "log" - - "example.com/app/policy" - "github.com/cipherstash/stack/languages/golang/stashgen" -) - -func main() { - if err := stashgen.Generate(policy.Source, policy.Individuals, stashgen.Output("individual_stash.go")); err != nil { - log.Fatal(err) - } -} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual.go b/docs/plans/2026-10-04-plan-builder/individuals/individual.go deleted file mode 100644 index 0120c907a..000000000 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package individuals must build without individual_stash.go, because the -// generate program imports it. Code that uses the generated names lives in -// individualstore. -package individuals - -//go:generate go run -tags stashgen ../cmd/genplans - -type Individual struct { - ID int64 - Name string - Email string - MedicareNo string - Nickname string -} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index ba652ebcf..53e36e4fb 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -1,28 +1,32 @@ // Code generated by stashgen. DO NOT EDIT. -//go:build !stashgen - package individuals import ( "context" "log/slog" + "example.com/app/internal/pb" "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" ) // Stops compiling when the library does not accept this version of generated file. const _ = gensupport.GeneratedVersion1 -// Individual prints its sealed fields in the clear: it has no String or -// LogValue method. Write them, or run stashgen with -redact. +// pb.Individual prints its sealed fields in the clear, and stashgen cannot add +// print methods to a type from another package. + +// pb.Individual has unexported fields, so Go cannot convert it to a copy of +// its fields. This file reads each field by name: the compiler finds a removed +// or retyped field, and CI finds an added one. type EncryptedIndividual struct { - ID int64 + Id int64 Name EncryptedIndividualName Email EncryptedIndividualEmail - MedicareNo EncryptedIndividualMedicareNo + MedicareNo eql.TextEq Nickname string } @@ -36,28 +40,12 @@ type EncryptedIndividualEmail struct { Match encrypt.MatchTerm } -type EncryptedIndividualMedicareNo struct { - Ciphertext encrypt.Ciphertext - Equality encrypt.EqualityTerm -} - func (e EncryptedIndividual) String() string { - return gensupport.Redacted("EncryptedIndividual", map[string]any{"ID": e.ID, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") + return gensupport.Redacted("EncryptedIndividual", map[string]any{"Id": e.Id, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") } func (e EncryptedIndividual) LogValue() slog.Value { - return gensupport.RedactedLog(map[string]any{"ID": e.ID, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") -} - -// Stops compiling when Individual gains, loses, reorders or retypes a field. -var _ = individualShape(Individual{}) - -type individualShape struct { - ID int64 - Name string - Email string - MedicareNo string - Nickname string + return gensupport.RedactedLog(map[string]any{"Id": e.Id, "Nickname": e.Nickname}, "Name", "Email", "MedicareNo") } var declaration = gensupport.Declare("individuals"). @@ -67,80 +55,111 @@ var declaration = gensupport.Declare("individuals"). EncryptIndex("medicare_number", encrypt.Equality). Passthrough("nickname") -var codec = gensupport.New(gensupport.Generated[Individual, EncryptedIndividual]{ - TypeName: "Individual", +var codec = gensupport.New(gensupport.Generated[*pb.Individual, EncryptedIndividual]{ + TypeName: "pb.Individual", Declaration: declaration, PrintsPlaintext: true, - Source: func(v Individual) gensupport.Values { - return gensupport.Values{"name": v.Name, "email": v.Email, "medicare_number": v.MedicareNo} + Source: func(v *pb.Individual) gensupport.Values { + return gensupport.Values{"name": v.GetName(), "email": v.GetEmail(), "medicare_number": v.GetMedicareNo()} }, - Seal: func(v Individual, rec gensupport.Record) EncryptedIndividual { - email, medicare := rec["email"], rec["medicare_number"] + Seal: func(v *pb.Individual, rec gensupport.Record) EncryptedIndividual { + email := rec["email"] return EncryptedIndividual{ - ID: v.ID, + Id: v.GetId(), Name: EncryptedIndividualName{Ciphertext: rec["name"].Ciphertext}, Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - MedicareNo: EncryptedIndividualMedicareNo{Ciphertext: medicare.Ciphertext, Equality: medicare.Equality}, - Nickname: v.Nickname, + MedicareNo: eql.NewTextEq(rec["medicare_number"]), + Nickname: v.GetNickname(), } }, Open: func(e EncryptedIndividual) gensupport.Record { return gensupport.Record{ "name": {Ciphertext: e.Name.Ciphertext}, "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, - "medicare_number": {Ciphertext: e.MedicareNo.Ciphertext, Equality: e.MedicareNo.Equality}, + "medicare_number": e.MedicareNo.Outputs(), } }, - Value: func(e EncryptedIndividual, vals gensupport.Values) (Individual, error) { - v := Individual{ID: e.ID, Nickname: e.Nickname} + Value: func(e EncryptedIndividual, vals gensupport.Values) (*pb.Individual, error) { + v := &pb.Individual{Id: e.Id, Nickname: e.Nickname} var err error if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { - return Individual{}, err + return nil, err } if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { - return Individual{}, err + return nil, err } if v.MedicareNo, err = gensupport.Get[string](vals, "medicare_number"); err != nil { - return Individual{}, err + return nil, err } return v, nil }, }) -func Encrypt(ctx context.Context, cipher *encrypt.Cipher, individuals []Individual) ([]EncryptedIndividual, error) { +func Encrypt(ctx context.Context, cipher *encrypt.Cipher, individuals []*pb.Individual) ([]EncryptedIndividual, error) { return codec.Encrypt(ctx, cipher, individuals) } -func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedIndividual) ([]Individual, error) { +func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedIndividual) ([]*pb.Individual, error) { return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedIndividual, individuals []Individual) encrypt.Operation { +func EncryptInto(dst *[]EncryptedIndividual, individuals []*pb.Individual) encrypt.Operation { return codec.EncryptInto(dst, individuals) } -func DecryptInto(dst *[]Individual, encrypted []EncryptedIndividual) encrypt.Operation { +func DecryptInto(dst *[]*pb.Individual, encrypted []EncryptedIndividual) encrypt.Operation { return codec.DecryptInto(dst, encrypted) } var Fields = struct { + Name NameField + Email EmailField MedicareNo MedicareNoField }{ + Name: NameField{gensupport.NewField[string](declaration, "name")}, + Email: EmailField{gensupport.NewField[string](declaration, "email")}, MedicareNo: MedicareNoField{gensupport.NewField[string](declaration, "medicare_number")}, } -type MedicareNoField struct { +type NameField struct { + field gensupport.Field[string] +} + +func (f NameField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedIndividualName, error) { + out, err := f.field.Encrypt(ctx, c, v) + return EncryptedIndividualName{Ciphertext: out.Ciphertext}, err +} + +type EmailField struct { field gensupport.Field[string] } -func (f MedicareNoField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedIndividualMedicareNo, error) { +func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedIndividualEmail, error) { out, err := f.field.Encrypt(ctx, c, v) if err != nil { - return EncryptedIndividualMedicareNo{}, err + return EncryptedIndividualEmail{}, err } - return EncryptedIndividualMedicareNo{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil + return EncryptedIndividualEmail{Ciphertext: out.Ciphertext, Equality: out.Equality, Match: out.Match}, nil } -func (f MedicareNoField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { +func (f EmailField) Equality(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { return f.field.Equality(ctx, c, v) } + +func (f EmailField) Match(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.MatchTerm, error) { + return f.field.Match(ctx, c, v) +} + +type MedicareNoField struct { + field gensupport.Field[string] +} + +func (f MedicareNoField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.NewTextEq(out), err +} + +func (f MedicareNoField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.NewTextEqQuery(out), err +} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/store.go b/docs/plans/2026-10-04-plan-builder/individuals/store.go new file mode 100644 index 000000000..7d97efea9 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/individuals/store.go @@ -0,0 +1,40 @@ +// Package individuals keeps protobuf Individual messages in Postgres. The +// rules in package rules decide how each field is encrypted, and stashgen +// writes individual_stash.go from them. +package individuals + +import ( + "context" + "database/sql" + + "example.com/app/internal/pb" + "github.com/cipherstash/stack/languages/golang/encrypt" +) + +func Create(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, people []*pb.Individual) error { + encrypted, err := Encrypt(ctx, cipher, people) + if err != nil { + return err + } + for _, e := range encrypted { + _, err := db.ExecContext(ctx, ` + INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + e.Id, e.Nickname, e.Name.Ciphertext, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, e.MedicareNo) + if err != nil { + return err + } + } + return nil +} + +func IDByMedicare(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, medicareNo string) (int64, error) { + query, err := Fields.MedicareNo.Query(ctx, cipher, medicareNo) + if err != nil { + return 0, err + } + var id int64 + err = db.QueryRowContext(ctx, + `SELECT id FROM individuals WHERE medicare_number = $1::eql_v3.query_text_eq`, query).Scan(&id) + return id, err +} diff --git a/docs/plans/2026-10-04-plan-builder/individualstore/store.go b/docs/plans/2026-10-04-plan-builder/individualstore/store.go deleted file mode 100644 index 10bb48bc0..000000000 --- a/docs/plans/2026-10-04-plan-builder/individualstore/store.go +++ /dev/null @@ -1,34 +0,0 @@ -// Package individualstore keeps individuals in Postgres. -package individualstore - -import ( - "context" - "database/sql" - - "example.com/app/individuals" - "github.com/cipherstash/stack/languages/golang/encrypt" -) - -func Create(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, person individuals.Individual) error { - encrypted, err := individuals.Encrypt(ctx, cipher, []individuals.Individual{person}) - if err != nil { - return err - } - e := encrypted[0] - _, err = db.ExecContext(ctx, ` - INSERT INTO individuals (id, nickname, name, email, email_eq, email_match, medicare_number, medicare_number_eq) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - e.ID, e.Nickname, e.Name.Ciphertext, e.Email.Ciphertext, e.Email.Equality, e.Email.Match, - e.MedicareNo.Ciphertext, e.MedicareNo.Equality) - return err -} - -func IDByMedicare(ctx context.Context, db *sql.DB, cipher *encrypt.Cipher, medicareNo string) (int64, error) { - term, err := individuals.Fields.MedicareNo.Equality(ctx, cipher, medicareNo) - if err != nil { - return 0, err - } - var id int64 - err = db.QueryRowContext(ctx, `SELECT id FROM individuals WHERE medicare_number_eq = $1`, term).Scan(&id) - return id, err -} diff --git a/docs/plans/2026-10-04-plan-builder/internal/pb/classification.pb.go b/docs/plans/2026-10-04-plan-builder/internal/pb/classification.pb.go new file mode 100644 index 000000000..1f2f7a736 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/pb/classification.pb.go @@ -0,0 +1,94 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.26.0 +// protoc (unknown) +// source: classification.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + descriptorpb "google.golang.org/protobuf/types/descriptorpb" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var file_classification_proto_extTypes = []protoimpl.ExtensionInfo{ + { + ExtendedType: (*descriptorpb.FieldOptions)(nil), + ExtensionType: ([]string)(nil), + Field: 50001, + Name: "classification.data_categories", + Tag: "bytes,50001,rep,name=data_categories", + Filename: "classification.proto", + }, +} + +// Extension fields to descriptorpb.FieldOptions. +var ( + // The Fideslang data categories of a field. + // + // repeated string data_categories = 50001; + E_DataCategories = &file_classification_proto_extTypes[0] +) + +var File_classification_proto protoreflect.FileDescriptor + +var file_classification_proto_rawDesc = []byte{ + 0x0a, 0x14, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x20, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3a, 0x48, 0x0a, 0x0f, 0x64, 0x61, 0x74, 0x61, + 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, + 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xd1, 0x86, 0x03, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0e, 0x64, 0x61, 0x74, 0x61, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, + 0x65, 0x73, 0x42, 0x1d, 0x5a, 0x1b, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, + 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var file_classification_proto_goTypes = []interface{}{ + (*descriptorpb.FieldOptions)(nil), // 0: google.protobuf.FieldOptions +} +var file_classification_proto_depIdxs = []int32{ + 0, // 0: classification.data_categories:extendee -> google.protobuf.FieldOptions + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 0, // [0:1] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_classification_proto_init() } +func file_classification_proto_init() { + if File_classification_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_classification_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 1, + NumServices: 0, + }, + GoTypes: file_classification_proto_goTypes, + DependencyIndexes: file_classification_proto_depIdxs, + ExtensionInfos: file_classification_proto_extTypes, + }.Build() + File_classification_proto = out.File + file_classification_proto_rawDesc = nil + file_classification_proto_goTypes = nil + file_classification_proto_depIdxs = nil +} diff --git a/docs/plans/2026-10-04-plan-builder/internal/pb/individual.pb.go b/docs/plans/2026-10-04-plan-builder/internal/pb/individual.pb.go new file mode 100644 index 000000000..e09773644 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/internal/pb/individual.pb.go @@ -0,0 +1,188 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.26.0 +// protoc (unknown) +// source: individual.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Individual struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + MedicareNo string `protobuf:"bytes,4,opt,name=medicare_no,json=medicareNo,proto3" json:"medicare_no,omitempty"` + Nickname string `protobuf:"bytes,5,opt,name=nickname,proto3" json:"nickname,omitempty"` +} + +func (x *Individual) Reset() { + *x = Individual{} + if protoimpl.UnsafeEnabled { + mi := &file_individual_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Individual) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Individual) ProtoMessage() {} + +func (x *Individual) ProtoReflect() protoreflect.Message { + mi := &file_individual_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Individual.ProtoReflect.Descriptor instead. +func (*Individual) Descriptor() ([]byte, []int) { + return file_individual_proto_rawDescGZIP(), []int{0} +} + +func (x *Individual) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Individual) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Individual) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *Individual) GetMedicareNo() string { + if x != nil { + return x.MedicareNo + } + return "" +} + +func (x *Individual) GetNickname() string { + if x != nil { + return x.Nickname + } + return "" +} + +var File_individual_proto protoreflect.FileDescriptor + +var file_individual_proto_rawDesc = []byte{ + 0x0a, 0x10, 0x69, 0x6e, 0x64, 0x69, 0x76, 0x69, 0x64, 0x75, 0x61, 0x6c, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x0b, 0x69, 0x6e, 0x64, 0x69, 0x76, 0x69, 0x64, 0x75, 0x61, 0x6c, 0x73, 0x1a, + 0x14, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc2, 0x01, 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x69, 0x76, 0x69, + 0x64, 0x75, 0x61, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x0d, 0x8a, 0xb5, 0x18, 0x09, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x6e, 0x61, 0x6d, + 0x65, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x16, 0x8a, 0xb5, 0x18, 0x12, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x63, 0x74, 0x2e, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x6d, 0x65, 0x64, 0x69, 0x63, 0x61, 0x72, + 0x65, 0x5f, 0x6e, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x16, 0x8a, 0xb5, 0x18, 0x12, + 0x75, 0x73, 0x65, 0x72, 0x2e, 0x67, 0x6f, 0x76, 0x65, 0x72, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x52, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x63, 0x61, 0x72, 0x65, 0x4e, 0x6f, 0x12, 0x1a, + 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x1d, 0x5a, 0x1b, 0x65, 0x78, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_individual_proto_rawDescOnce sync.Once + file_individual_proto_rawDescData = file_individual_proto_rawDesc +) + +func file_individual_proto_rawDescGZIP() []byte { + file_individual_proto_rawDescOnce.Do(func() { + file_individual_proto_rawDescData = protoimpl.X.CompressGZIP(file_individual_proto_rawDescData) + }) + return file_individual_proto_rawDescData +} + +var file_individual_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_individual_proto_goTypes = []interface{}{ + (*Individual)(nil), // 0: individuals.Individual +} +var file_individual_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_individual_proto_init() } +func file_individual_proto_init() { + if File_individual_proto != nil { + return + } + file_classification_proto_init() + if !protoimpl.UnsafeEnabled { + file_individual_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Individual); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_individual_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_individual_proto_goTypes, + DependencyIndexes: file_individual_proto_depIdxs, + MessageInfos: file_individual_proto_msgTypes, + }.Build() + File_individual_proto = out.File + file_individual_proto_rawDesc = nil + file_individual_proto_goTypes = nil + file_individual_proto_depIdxs = nil +} diff --git a/docs/plans/2026-10-04-plan-builder/policy/policy.go b/docs/plans/2026-10-04-plan-builder/policy/policy.go deleted file mode 100644 index 7eb44f76f..000000000 --- a/docs/plans/2026-10-04-plan-builder/policy/policy.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package policy decides what to encrypt from the data categories a schema -// gives each field. It runs in the generate program, never in the application. -package policy - -import ( - "example.com/app/individuals" - "github.com/cipherstash/stack/languages/golang/encrypt" - "github.com/cipherstash/stack/languages/golang/encrypt/plan" -) - -var category = plan.Key("fides.data_categories") - -func categories(values ...string) []plan.Annotation { - return []plan.Annotation{{Key: string(category), Values: values}} -} - -// Source states the facts a schema reader would produce. A protobuf source -// reads the same facts from descriptors and their custom options. -// -// A fact has two names because the schema and the Go struct spell a field -// differently. Field is the schema's name: rules match on it, and it names -// the column unless plan.Column sets another. GoField is the field of -// individuals.Individual that holds the value, and the generator gives the -// field of EncryptedIndividual the same name. With no GoField, Field is both. -var Source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { - return []plan.Fact{ - {Field: "id", GoField: "ID"}, - {Field: "name", GoField: "Name", Annotations: categories("user.name")}, - {Field: "email", GoField: "Email", Annotations: categories("user.contact.email")}, - {Field: "medicare_no", GoField: "MedicareNo", Annotations: categories("user.government_id")}, - {Field: "nickname", GoField: "Nickname"}, - }, nil -}) - -var Base = plan.FirstOf( - plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(encrypt.Equality))), - plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(encrypt.Equality, encrypt.Match()))), - plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), -) - -var Individuals = plan.ForMessage(individuals.Individual{}, plan.Table("individuals"), - plan.FirstOf( - plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(encrypt.Equality)), - plan.Column("medicare_number")), - ).OrElse(Base), -) diff --git a/docs/plans/2026-10-04-plan-builder/proto/buf.gen.yaml b/docs/plans/2026-10-04-plan-builder/proto/buf.gen.yaml new file mode 100644 index 000000000..4a91af7da --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/proto/buf.gen.yaml @@ -0,0 +1,5 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: ../internal/pb + opt: paths=source_relative diff --git a/docs/plans/2026-10-04-plan-builder/proto/classification.proto b/docs/plans/2026-10-04-plan-builder/proto/classification.proto new file mode 100644 index 000000000..6b2180c24 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/proto/classification.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package classification; + +import "google/protobuf/descriptor.proto"; + +option go_package = "example.com/app/internal/pb"; + +extend google.protobuf.FieldOptions { + // The Fideslang data categories of a field. + repeated string data_categories = 50001; +} diff --git a/docs/plans/2026-10-04-plan-builder/proto/individual.proto b/docs/plans/2026-10-04-plan-builder/proto/individual.proto new file mode 100644 index 000000000..a22e16802 --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/proto/individual.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package individuals; + +import "classification.proto"; + +option go_package = "example.com/app/internal/pb"; + +message Individual { + int64 id = 1; + string name = 2 [(classification.data_categories) = "user.name"]; + string email = 3 [(classification.data_categories) = "user.contact.email"]; + string medicare_no = 4 [(classification.data_categories) = "user.government_id"]; + string nickname = 5; +} diff --git a/docs/plans/2026-10-04-plan-builder/rules/rules.go b/docs/plans/2026-10-04-plan-builder/rules/rules.go new file mode 100644 index 000000000..d3a95643c --- /dev/null +++ b/docs/plans/2026-10-04-plan-builder/rules/rules.go @@ -0,0 +1,31 @@ +// Package rules decides what to encrypt from the data categories that the +// protobuf schema gives each field. Only the generate program runs it. +package rules + +import ( + "example.com/app/internal/pb" + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/policy" +) + +//go:generate go run ../cmd/genencrypt + +// The key is the full name of the field option in proto/classification.proto. +var category = policy.Key("classification.data_categories") + +// Base applies to every message. The first rule that matches a field decides it. +var Base = policy.FirstOf( + policy.When(category.Under("user.government_id"), policy.EncryptInto("TextEq")), + policy.When(category.Under("user.contact.email"), policy.EncryptIndex(encrypt.Equality, encrypt.Match())), + policy.When(category.Under("user"), policy.Encrypt()), +) + +// Individuals adds the rules for one message. A field with no data category +// needs a rule too: with none, the generator stops. +var Individuals = policy.ForMessage(&pb.Individual{}, policy.Context("individuals"), + policy.FirstOf( + policy.When(policy.Field("medicare_no"), policy.EncryptInto("TextEq"), policy.Name("medicare_number")), + policy.When(policy.Field("id"), policy.Passthrough()), + policy.When(policy.Field("nickname"), policy.Passthrough()), + ).OrElse(Base), +) diff --git a/docs/sdk-design-principles.md b/docs/sdk-design-principles.md index 55cd69051..2d6e8896e 100644 --- a/docs/sdk-design-principles.md +++ b/docs/sdk-design-principles.md @@ -210,7 +210,6 @@ The generator does not guess a plural. ## Not yet decided -- The policy package's approach. - How SDK tools get the engine's rules. - Query building in Go. - The design of the `go vet` check. From 75b422d091f192d31fefeb6e947976a0ab96d55a Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 06:23:33 +1100 Subject: [PATCH 18/22] docs(plans): settle the review's conflicts with the base plan A review of #1070 found claims that disagreed with decisions on #1052. This commit settles the ones that have a ruling. A field crosses the binding only when its value does. The section said generated code sends the full declaration and skips passthrough values. Decision 9 of the plan says every plan field must be present in the value. Agreed with Dan: a field with no value sends no declaration, so decision 9 holds and the data grammar does not change. The generated file still names every field, because that file is what a reviewer reads. The record fixture replaces the golden snapshots as the proof of the lowering. Sequencing item 7 rested on plantest.Golden, and this design removes the package that writes those snapshots. The fixture compares term bytes and checks that each side opens the other's records, because a ciphertext is not the same bytes twice. The sentence about #1025 is gone: it merged. The generator checks a declaration with the embedded guest. A second copy of the engine's rules in stashgen is the "lives twice" cost that ADR-0007 accepted only for an encoder. It was an open question. The EQL envelope is stated. eql-bindings writes the eql_v3 types with SchemaVersion 3 and a ciphertext prefixed "stack-encrypt:1:", and its module doc says the envelope and SQL domains are unchanged. "EQL v4" in the plan names that form. Principle 5 forbade a single-value form while a field entry's Encrypt takes one value. The exception is now written. The principles ADR moves to the stack-encrypt series as ADR-0008. docs/adr/0002 and packages/stack-encrypt/docs/adr/0002 were two different ADR-0002s, and this ADR rests on ADR-0007 in that series. The glossary gains Binding, Language SDK and Declaration, which the principles define and the glossary used loosely. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- AGENTS.md | 2 +- docs/plans/2026-10-04-plan-builder.md | 33 ++++++++++++------- docs/plans/2026-10-04-plan-builder/README.md | 2 ++ docs/sdk-design-principles.md | 10 +++--- packages/stack-encrypt/CONTEXT.md | 18 ++++++++++ .../0008-language-sdk-design-principles.md | 8 ++--- 6 files changed, 53 insertions(+), 20 deletions(-) rename docs/adr/0002-language-sdk-design-principles.md => packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md (85%) diff --git a/AGENTS.md b/AGENTS.md index c2af35d07..ce21666b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -768,7 +768,7 @@ Read [`docs/sdk-design-principles.md`](docs/sdk-design-principles.md) before you architect, design or build a language SDK, or change the Go module's public surface. It holds eight principles for every language SDK and thirteen for the Go SDK, and the order to apply them in when two disagree. -`docs/adr/0002-language-sdk-design-principles.md` records why. +`packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md` records why. Two terms from it are used across this repository: diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 1473910dc..5ce9dc598 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -637,6 +637,10 @@ In the `Text` family, the three `Ord` suffixes carry equality too. Each type has a query type, with `Query` after its name: `TextEqQuery`. The value of `encrypt_into` is the Go type name. +An EQL value has the EQL v3 envelope: its version field is `3`, and Postgres stores it in an `eql_v3` domain. +The ciphertext inside it is a Stack Encrypt ciphertext, which starts with `stack-encrypt:1:`. +"EQL v4" in this plan is the name of that form, and not a new envelope. + The engine produces one EQL type today: `TextEq`. The other types wait for work in the engine: @@ -847,6 +851,7 @@ The compiler then finds a removed field and a field with a new type, and CI find The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text. It runs none of the package's code. It ignores its own output file when it loads the package, so a stale file does not stop it. +It checks each declaration with the engine: it runs the guest that the SDK embeds, and it holds no copy of the engine's rules. The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. `stashgen` stops with an error, and writes no file, for each of these: @@ -953,12 +958,20 @@ No function in the SDK or in generated code panics for a declaration, and none h ### What crosses the binding The engine does all encryption, decryption and term derivation. -Generated code sends the engine the full declaration: every field, including each passthrough field and each field left out. -It sends the value of each sealed field. -It does not send the value of a passthrough field, and it copies that value to the generated type itself. +A field crosses the binding only when its value does. +Generated code sends the engine a declaration and a value for each sealed field and each indexed field. +It sends nothing for a passthrough field, and it copies that value to the generated type itself. +It sends nothing for a field that is left out. + +So every field the engine is told of has a value, and every value has a field. +The generated file still names every field, so a reviewer reads the whole declaration. Generated code assembles an EQL value from the engine's ciphertext and terms. -A cross-language fixture guards those bytes: encode in Rust, decode and encode again in Go, and compare. +Two fixtures that both test suites read guard the bytes: + +- **The EQL fixture:** encode in Rust, decode and encode again in Go, and compare. +- **The record fixture:** the Rust chain and generated Go code each open the records that the other encrypted. + Both derive the same bytes for each term. The package `encrypt/gensupport` holds what only generated code calls. No function in it panics. @@ -1192,10 +1205,11 @@ Next, as stacked drafts on #1071 (4, 5 and 6 are open drafts): Then: -7. **`dynamic::record` becomes a lowering** (#1059). The guest is rebuilt; - Go's `plantest.Golden` snapshots must not change, which is the proof. -8. **The Go binding** (#1046), against the design in #1070. #1025 merges - first so snapshots regenerate once. +7. **`dynamic::record` becomes a lowering** (#1059). The guest is rebuilt. + The record fixture is the proof: the Rust chain and the lowering each + open the records that the other encrypted, and both derive the same + bytes for each term. +8. **The Go SDK** (#1046), against the design in #1070. 9. **The EQL typed verb** (#1062), Rust-only; Go assembles EQL types through the generator. 10. The audit-context PR, then the lock-context PR, on the finished shape. @@ -1219,9 +1233,6 @@ Then: This design covers one version of a declaration. Reading data that an older declaration wrote needs its own design. A policy has `Identity` for a column with a new name, and tags have no word for it yet. -- **How the generator gets the engine's rules.** - The generator must refuse what the engine refuses, from one source of rules. - Whether it calls the engine or reads rules the engine publishes is not decided. - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index 9c0d2972e..7514c7587 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -39,6 +39,8 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces | A generated file from another version does not compile | Run. A file that names an unknown version constant fails `go build`. | | The files `stashgen` writes | Not run. `stashgen` does not exist, and the five `_stash.go` files are written by hand. | | The SDK can be built with these signatures | Not run. | +| The record fixture and the EQL fixture | Not run. Neither fixture exists. | +| `stashgen` checks a declaration with the embedded guest | Not run. | | The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. | | The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | | The steps in "Use the SDK" | Not run. Nobody has followed them. | diff --git a/docs/sdk-design-principles.md b/docs/sdk-design-principles.md index 2d6e8896e..b3705691b 100644 --- a/docs/sdk-design-principles.md +++ b/docs/sdk-design-principles.md @@ -3,7 +3,7 @@ These principles apply when you architect, design or build a language SDK for Stack Encrypt. The first part applies to every language. The second part applies to the Go SDK. -[ADR-0002](adr/0002-language-sdk-design-principles.md) records the decision to adopt them. +[ADR-0008](../packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md) records the decision to adopt them. ## Terms @@ -30,8 +30,10 @@ All encryption, decryption and term derivation happens in the engine. An SDK declares what to do and sends that across the binding as data. An SDK does not have its own loop over fields, its own batching, or its own sealing. -- An SDK sends the full declaration, with every field in it. -- An SDK can skip the value of a field when the engine computes nothing from it and the host's types guarantee the value is present. +- A field crosses the binding only when its value does: an SDK sends a field's declaration with the field's value. +- An SDK can keep a field on its own side when the engine computes nothing from it. + It then sends no declaration and no value for that field. +- An SDK tool that checks a declaration asks the engine, and holds no copy of the engine's rules. - An SDK can assemble a wire format in the host language only when a cross-language test compares the bytes. ### 2. A language SDK takes its host language's shape @@ -76,6 +78,7 @@ One call from the user makes one request to the key service, however many values The natural way to call an SDK is the efficient way. - An SDK does not offer a single-value form beside the batch form. + The one exception is a call on one field, for an update of one column or for a search value. - An SDK can put operations on different types in one request. ### 6. A second way in has to earn its place @@ -210,7 +213,6 @@ The generator does not guess a plural. ## Not yet decided -- How SDK tools get the engine's rules. - Query building in Go. - The design of the `go vet` check. - A change to a declaration over time. diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 6a4293664..e1e7cff02 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -25,6 +25,24 @@ binding reaches indexes and field-by-field encryption only through a plan lowered from data, which runs the same engine. _Avoid_: typed path, high-level path +**Binding**: +The FFI or WASI interface between the engine and a target language: the +guest's exports and the data that crosses them. A user of that language does +not call it. +_Avoid_: SDK, mirror, name binding (that is a keyset name's) + +**Language SDK**: +What users of a target language work with day to day. In Go: struct tags, the +generator, and the code the generator writes. It reaches the engine only +through the binding. +_Avoid_: binding, wrapper, mirror + +**Declaration**: +The statement of how each field is encrypted: its context and its indexes. +It is what a user of a language SDK writes; in Go, struct tags or a policy. +A saved declaration is a plan. +_Avoid_: schema, config, mapping + **Operation description**: The target's declaration of the ciphertext and term operations, source selections, and context requirements needed to produce it. diff --git a/docs/adr/0002-language-sdk-design-principles.md b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md similarity index 85% rename from docs/adr/0002-language-sdk-design-principles.md rename to packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md index 5165e9290..8d332eeac 100644 --- a/docs/adr/0002-language-sdk-design-principles.md +++ b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md @@ -5,7 +5,7 @@ date: 2026-10-05 # Language SDKs follow one set of design principles -Every language SDK for Stack Encrypt follows [the language SDK design principles](../sdk-design-principles.md). +Every language SDK for Stack Encrypt follows [the language SDK design principles](../../../../docs/sdk-design-principles.md). That document holds the principles. This ADR records the decision to adopt them, and why. @@ -25,7 +25,7 @@ There was no written answer, so each language SDK would have argued them from th ## Decision -We adopt the principles in [`docs/sdk-design-principles.md`](../sdk-design-principles.md). +We adopt the principles in [`docs/sdk-design-principles.md`](../../../../docs/sdk-design-principles.md). Eight apply to every language SDK, and thirteen apply to the Go SDK. The document also fixes two terms. @@ -43,6 +43,6 @@ When two principles disagree, the document gives the order to apply them in. An SDK in a dynamic language ships rules for the language's type checkers and linters. - Each language SDK is its own design. The principles say what must match between them: the bytes, the words for engine behaviour, and fail-closed behaviour. -- Four questions are open, and the principles document lists them. +- Three questions are open, and the principles document lists them. -ADR-0007 in `packages/stack-encrypt/docs/adr/` is the ground for the first principle: an SDK enters the engine through a declaration, and never through a second executor. +ADR-0007 is the ground for the first principle: an SDK enters the engine through a declaration, and never through a second executor. From 2000cad3ac691f59efdaf529261e210035dd139b Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 07:54:34 +1100 Subject: [PATCH 19/22] docs(plans): one call covers one type, and a whole value has a declared context Two more items from the review of #1070. encrypt.Batch is removed, with EncryptInto, DecryptInto and Operation. One ZeroKMS request for several types needs the guest to take several declarations in one call: se_encrypt_record takes one plan today. It also needs the engine to run several plans under one key request, and Dan confirmed that the Rust side does not exist. A design that names a call the engine cannot serve is a claim that was not run. The work is listed as an open question, and the Go call is designed after it. The Go SDK has no cipher-directed path, and the plan now says so. An opaque struct goes to the engine with a declaration, so a sealed value always has a declared context, and Cipher.Extend appends to that context. A call that takes its own context lets the write and the read disagree, which principle 4 forbids. The glossary said cipher-directed is the one path every binding has natively; that sentence is amended, and ADR-0008 records the consequence. The guest's value exports have no caller in Go. Principle 7 keeps history out of a design document, and this plan holds "Why the first draft was dropped" and the rejected names. The principle keeps its text. The move of that history to ADR-0007 is an open item that Dan owns, because those sections are his. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 37 ++++++++++--------- .../accounts/account_stash.go | 8 ---- .../contacts/contactstash_stash.go | 8 ---- .../documents/document_stash.go | 8 ---- .../individuals/individual_stash.go | 8 ---- docs/plans/2026-10-04-plan-builder/main.go | 11 ++---- .../users/user_stash.go | 10 ----- packages/stack-encrypt/CONTEXT.md | 5 ++- .../0008-language-sdk-design-principles.md | 6 +++ 9 files changed, 31 insertions(+), 70 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 5ce9dc598..126127009 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -594,6 +594,11 @@ An `opaque` struct has no tags on its fields. Nothing inside it can be read or searched on its own. A value with no struct around it is a struct with one field. +An `opaque` struct is the one way to seal a whole value. +It goes to the engine with a declaration, as every other struct does. +So a sealed value always has a declared context, and `Extend` appends to it. +No call seals a value under a context that the caller passes, or under none. + ### Columns A field maps to its columns in one of two layouts. @@ -669,8 +674,6 @@ For `-type User`, the file `user_stash.go` holds: - **`Fields`.** It has one entry for each sealed field. An entry encrypts one value of that field, and it has a query method only for what the field declares. -- **`EncryptInto` and `DecryptInto`.** - They describe the same work for a batch, and name the variable that gets the result. - **Print methods on `EncryptedUser`.** `String` and `LogValue` print the passthrough fields and hide the sealed ones. - **The declaration.** @@ -689,9 +692,6 @@ See [`users/model.go`](2026-10-04-plan-builder/users/model.go) and [`users/user_ | `users.Fields.Email.Encrypt(ctx, c, v string)` | the field's generated type, for an update of one column | | `users.Fields.Email.Query(ctx, c, v string)` | an EQL query value, for a field with `encrypt_into` | | `users.Fields.Email.Equality`, `.Match`, `.Ore`, `.Ope` | one term, for a field with `index=` | -| `users.EncryptInto(dst *[]EncryptedUser, vs []User)` | a `encrypt.Operation`, for a batch | -| `users.DecryptInto(dst *[]User, es []EncryptedUser)` | a `encrypt.Operation`, for a batch | -| `encrypt.Batch(ctx, c, ops ...Operation)` | nothing; it writes each result to its `dst` | Every call also returns an `error`. `Encrypt` and `Decrypt` take a slice, and send one ZeroKMS request for all of it. @@ -701,19 +701,7 @@ For one value, pass a slice with one element. A field entry's `Encrypt` and its query methods take one value. A term derives with no request, so there is nothing to batch. -`Batch` runs operations on any number of types in one ZeroKMS request. -It writes each result to the variable that the operation names. -The compiler checks that the variable has the result's type. - -```go -var encryptedUsers []users.EncryptedUser -var encryptedContacts []contacts.EncryptedContact - -err := encrypt.Batch(ctx, cipher, - users.EncryptInto(&encryptedUsers, people), - contacts.EncryptInto(&encryptedContacts, list), -) -``` +Each call sends one request, so two types take two calls and two requests. `*Client` and `*Cipher` both implement `Decrypter`. A `*Client` decrypts each value under the keyset that sealed it. @@ -728,6 +716,7 @@ cipher := client.Keyset(encrypt.KeysetName("tenant-42")).Extend("tenant-42") ``` `Extend` returns a cipher that extends the context of every field, in every call through it. +It appends to the context that the tags declare, and it never replaces that context. No call takes a keyset or a context. So the write, the query and the read cannot use different ones. @@ -966,6 +955,9 @@ It sends nothing for a field that is left out. So every field the engine is told of has a value, and every value has a field. The generated file still names every field, so a reviewer reads the whole declaration. +Every call to the guest carries a declaration. +The guest's exports that take a value and no declaration have no caller in Go. + Generated code assembles an EQL value from the engine's ciphertext and terms. Two fixtures that both test suites read guard the bytes: @@ -1223,6 +1215,15 @@ Then: ## Open questions +- **One request for several types.** + One call covers one type today, because the guest takes one declaration in a call. + One request for several types needs the engine to run several plans under one key request. + It also needs a guest export that takes several declarations, each with its values. + The Go call for it is designed after that work. +- **The history in this plan.** + "Why the first draft was dropped" and the rejected names under "Decisions" are history. + The design principles keep history out of a design document, so those parts move to ADR-0007. + Dan Draper owns that move. - **Query building in Go.** The SDK gives the values for a search, and the program writes the SQL. A design for building that SQL is separate work. diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go index 6adbfe852..5e3a04b17 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -89,14 +89,6 @@ func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedAcco return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedAccount, accounts []Account) encrypt.Operation { - return codec.EncryptInto(dst, accounts) -} - -func DecryptInto(dst *[]Account, encrypted []EncryptedAccount) encrypt.Operation { - return codec.DecryptInto(dst, encrypted) -} - var Fields = struct { Email EmailField }{ diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index def357659..2f3e05233 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -100,14 +100,6 @@ func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedCont return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedContact, contacts []crm.Contact) encrypt.Operation { - return codec.EncryptInto(dst, contacts) -} - -func DecryptInto(dst *[]crm.Contact, encrypted []EncryptedContact) encrypt.Operation { - return codec.DecryptInto(dst, encrypted) -} - var Fields = struct { Email EmailField PhoneNumber PhoneNumberField diff --git a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go index c773dc583..1d483c409 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go +++ b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go @@ -79,11 +79,3 @@ func Encrypt(ctx context.Context, cipher *encrypt.Cipher, documents []Document) func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedDocument) ([]Document, error) { return codec.Decrypt(ctx, d, encrypted) } - -func EncryptInto(dst *[]EncryptedDocument, documents []Document) encrypt.Operation { - return codec.EncryptInto(dst, documents) -} - -func DecryptInto(dst *[]Document, encrypted []EncryptedDocument) encrypt.Operation { - return codec.DecryptInto(dst, encrypted) -} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index 53e36e4fb..d0d087462 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -103,14 +103,6 @@ func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedIndi return codec.Decrypt(ctx, d, encrypted) } -func EncryptInto(dst *[]EncryptedIndividual, individuals []*pb.Individual) encrypt.Operation { - return codec.EncryptInto(dst, individuals) -} - -func DecryptInto(dst *[]*pb.Individual, encrypted []EncryptedIndividual) encrypt.Operation { - return codec.DecryptInto(dst, encrypted) -} - var Fields = struct { Name NameField Email EmailField diff --git a/docs/plans/2026-10-04-plan-builder/main.go b/docs/plans/2026-10-04-plan-builder/main.go index 5397a72c1..a3317e481 100644 --- a/docs/plans/2026-10-04-plan-builder/main.go +++ b/docs/plans/2026-10-04-plan-builder/main.go @@ -58,14 +58,9 @@ func run(ctx context.Context) error { return err } - // Two types in one ZeroKMS request. + // One call for each type, and one ZeroKMS request for each call. list := []crm.Contact{{ID: 9, Email: "dan@example.com", PhoneNumber: "+61 400 000 000"}} - var encryptedUsers []users.EncryptedUser - var encryptedContacts []contacts.EncryptedContact - err = encrypt.Batch(ctx, cipher, - users.EncryptInto(&encryptedUsers, newHires), - contacts.EncryptInto(&encryptedContacts, list), - ) + encryptedContacts, err := contacts.Encrypt(ctx, cipher, list) if err != nil { return err } @@ -79,7 +74,7 @@ func run(ctx context.Context) error { } // Print ids and counts only. Every other value here is plaintext. - fmt.Println("bob:", ids(bobs), "batched:", len(encryptedUsers), len(encryptedContacts)) + fmt.Println("bob:", ids(bobs), "contacts:", len(encryptedContacts)) return nil } diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index c959205df..b184ac714 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -89,16 +89,6 @@ func Decrypt(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedUser return codec.Decrypt(ctx, d, encrypted) } -// EncryptInto and DecryptInto describe the same work for encrypt.Batch, -// which runs it and writes the result to dst. -func EncryptInto(dst *[]EncryptedUser, users []User) encrypt.Operation { - return codec.EncryptInto(dst, users) -} - -func DecryptInto(dst *[]User, encrypted []EncryptedUser) encrypt.Operation { - return codec.DecryptInto(dst, encrypted) -} - var Fields = struct { Email EmailField Name NameField diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index e1e7cff02..cf52e9497 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -11,8 +11,9 @@ derivation of searchable index terms from the same values. Covers Encryption driven by the value's shape: the value's `Encrypt` implementation walks the cipher and the caller decides the context, which may be absent. Nothing declares an index and no output type is involved. One of the two -preferred entry points for a native Rust caller, and the one path every -binding has natively. +preferred entry points for a native Rust caller. A language SDK may leave it +out: the Go SDK seals a whole value through a plan, so that every sealed +value has a declared context. _Avoid_: raw path, low-level path **Target-directed**: diff --git a/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md index 8d332eeac..027b359ae 100644 --- a/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md +++ b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md @@ -41,6 +41,12 @@ When two principles disagree, the document gives the order to apply them in. Its users write struct tags and call generated functions, and they never see a plan. - An SDK in a typed language finds most mistakes before the program runs. An SDK in a dynamic language ships rules for the language's type checkers and linters. +- The Go SDK has no cipher-directed path. + A whole value is sealed through a declaration, so it always has a declared context. + A caller's context appends to the declared one. + ADR-0003 keeps the cipher-directed path open for Rust, and that does not change. +- One request for several types needs work in the engine and in the guest first. + Until then, one call covers one type. - Each language SDK is its own design. The principles say what must match between them: the bytes, the words for engine behaviour, and fail-closed behaviour. - Three questions are open, and the principles document lists them. From 677f6634d724049771cdd277f44af80ce16bbedf Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 09:33:28 +1100 Subject: [PATCH 20/22] docs(adr): amend ADR-0004 to ADR-0007 for the Go SDK The review of #1070 found four ADRs whose text the Go SDK design leaves wrong. Each gets a dated amendment, and each amendment names the principle it rests on, so a reader can trace the change back. ADR-0007 gains the binding and language SDK words, the rule that a field crosses the binding only with its value, the record fixture as the proof of the lowering, the generator checking declarations with the embedded guest so the engine's rules have one source, and the fact that the guest takes one plan in one call. The value exports stay for another host of the guest and are not a Go path. ADR-0005 decision 4 named bindings/go, stackencrypt and stackauth. The module is at languages/golang and the packages are encrypt and auth. ADR-0006 said the Go binding has a Label held to Rust's by a fixture. The Go SDK has no Label: the segments come from the context= tag and the field name, and the engine's own parser checks them at go generate. The Go half of the fixture is retired with the Go Label. ADR-0004 decision 5 is convention in Rust and structural in Go, because Go has no standalone term derivation. Decision 6's plan-versus-value check moves to go generate for Go, with the engine's check as backstop. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- ...t-threaded-through-the-declaration-tree.md | 29 +++++++++++ ...edential-guest-for-the-profile-and-auth.md | 19 +++++++ ...ender-with-a-slash-and-describe-is-open.md | 22 ++++++++ ...-through-a-plan-never-a-second-executor.md | 52 +++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index 8458d34c3..99acd0656 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -315,3 +315,32 @@ What it does **not** fix: a term and a ciphertext written through two separate top-level calls still have no relation to each other, because neither knows the other exists. Decision 5 narrows this to callers who deliberately bypass the target layer on the write side. + +## Amended 2026-10-06 (#1070) + +G1 to G8 and Go-1 to Go-13 name the principles in +`docs/sdk-design-principles.md`, general and Go. + +Two decisions change shape in the Go SDK and keep their substance. + +**Decision 5** keeps standalone term derivation as the query path and directs +a stored term to a target, by convention and documentation, because the term +methods cannot tell a probe from a stored term. In Go the rule is structural. +The Go SDK has no `Cipher.Term`. A stored term exists only in generated code, +beside its ciphertext, under the one context the declaration gives the field. +A probe comes from a field entry's query method, which exists only for an +index the field declares. The compiler refuses a query the field does not +declare (G4: one declaration serves the write, the query and the read; Go-2: +the caller reaches every output through a field). + +**Decision 6** puts the plan-versus-value check at the FFI boundary, as plan +validation, with the field named and before any key is requested. In Go that +check moves earlier. `stashgen` checks the Go type against the declaration at +`go generate`, by running the guest the SDK embeds, so a field type the engine +cannot seal or an index that does not fit stops the generator. The engine's +own check at the boundary stays as the backstop (G3: the earliest stage the +language allows; Go-1). The dynamic-to-static dispatch itself is unchanged. + +Decisions 1 to 4, 7 and 8 do not change. Decision 2's `extend` is what +`Cipher.Extend` does from Go: a caller's context appends to the declared one, +and never replaces it. diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md index 7cd2301fe..45a71fdb6 100644 --- a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -248,3 +248,22 @@ crypto guest. A binary that encrypts now carries both guests. Neither sandbox changes. The crypto guest still has no environment and no filesystem, and the credential guest still has one mount. With no profile directory, `stackauth.OpenWithoutProfile` gives it none. + +## Amendment (2026-10-06, #1070): the module at `languages/golang`, the packages `encrypt` and `auth` + +G1 to G8 and Go-1 to Go-13 name the principles in +`docs/sdk-design-principles.md`, general and Go. + +Decision 4 named the module `bindings/go` and the packages `stackencrypt` and +`stackauth`. The module is at `languages/golang`, beside the other language +SDKs, and the packages are `encrypt` and `auth`. + +The reason is Go-13: a name says what the thing is for, and no name repeats +its package. Go code reads a package name at every use. `encrypt.Cipher` and +`auth.ProfileStore` say what they are; `stackencrypt.Cipher` repeats the +module's name on every line. ADR-0008 names the user-facing code the Go SDK, +not the Go binding, so the directory is not `bindings`. + +Everything else in decision 4 holds: one module, one internal package both +import, `auth` does not import `encrypt`, and `ClientKey` is one type under +both names. The 2026-09-27 amendment holds too: `encrypt` imports `auth`. diff --git a/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md b/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md index a1b77e300..ad1548f04 100644 --- a/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md +++ b/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md @@ -119,3 +119,25 @@ part and otherwise the field name is used; a nested record is an ordinary typed field sealed under `/`, its inner layout being its own type's business. The struct-level `context = ""` form and the rest of this decision are unchanged. + +## Amended 2026-10-06 (#1070) + +G1 to G8 and Go-1 to Go-13 name the principles in +`docs/sdk-design-principles.md`, general and Go. + +The `Label` decision said the Go binding has the same type and the same +segment rule, held together by one fixture both test suites read. The Go SDK +has no `Label`, `Context`, `NewContext` or `ParseLabel`. A field's two +segments are the value of the struct's `context=` tag and the field's name +(Go-7: the user never sees the plan, and no call takes a context, G4). + +The segment rule has one implementation. `stashgen` checks a declaration by +running the guest the SDK embeds (ADR-0007, amended), so a `/` or an escaped +character in a `context=` value is refused at `go generate` by the engine's +own `Label` parser (G1, Go-1). The Go half of the fixture is retired with the +Go `Label`; the Rust half stays, and the record fixture in ADR-0007 covers the +bytes Go and Rust must agree on. + +The derive's `struct = .., context = ""` form and the Go tag bind the +same pair, so a row the derive writes opens through the Go SDK and the +reverse. That is the interoperability this ADR was for. diff --git a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md index edb96006b..ce96c3cf8 100644 --- a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md +++ b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md @@ -11,6 +11,11 @@ extends: ADR-0003, ADR-0004 > (after its grammar is narrowed to the plan's), how EQL types are assembled > (per language, from standard outputs), and the consequence that the EQL > encoding lives twice. +> +> **Amended 2026-10-06**, by #1070. The decision is unchanged. Amended: the +> words binding and language SDK, what crosses the binding from Go, the proof +> of the lowering, where the generator gets the engine's rules, and what the +> guest takes in one call. Stack Encrypt has one execution engine: the `Encryption` and `Decryption` descriptions in `target/` and the batched `Pending` they produce. ADR-0003 @@ -111,3 +116,50 @@ Option 3. The design history, the names rejected on the way and the sequencing are in `docs/plans/2026-10-04-plan-builder.md`. + +## Amended 2026-10-06 (#1070) + +G1 to G8 and Go-1 to Go-13 name the principles in +`docs/sdk-design-principles.md`, general and Go. + +ADR-0008 fixes two words this ADR used as one. A **binding** is the WASI or +FFI interface between the engine and a language: the guest's exports and the +data that crosses them. A **language SDK** is what users of that language work +with. The decision above is about the binding: a plan is the one thing that +crosses it. The Go SDK is struct tags, a generator and generated code, and its +users never see a plan (Go-7). Each "binding" above that names Go reads as the +Go SDK's generated code. + +**A field crosses the binding only when its value does.** The first Go design +sent the full declaration and skipped passthrough values, which contradicted +decision 9 of the plan: every plan field is present in the value. Generated Go +code now sends a declaration and a value for each sealed and each indexed +field, and nothing for a passthrough or omitted field. The data grammar does +not change. The generated file still names every field, so a reviewer reads +the whole declaration (G1, Go-10). + +**The record fixture is the proof of the lowering.** Sequencing rested on Go's +`plantest.Golden` snapshots not changing, and the Go SDK removes the package +that writes them. The proof is now a fixture both test suites read: the Rust +chain and the lowering each open the records that the other encrypted, and +both derive the same bytes for each term. The same fixture later holds +generated Go code to the Rust chain (G7: a claim is run before it is written). + +**The generator asks the engine, and holds no copy of its rules.** `stashgen` +refuses an index that does not fit a Go type and an EQL type the engine cannot +produce. A second copy of those rules in Go would be the "lives twice" cost +this ADR accepts only for an encoder. `stashgen` runs the guest the SDK embeds +to check each declaration at `go generate`, so the rules have one source (G1) +and the check runs at the earliest stage Go allows (G3, Go-1). + +**The guest takes one plan in one call.** `se_encrypt_record` takes one plan, +and the engine runs one plan under one key request. So one Go call covers one +type, and one request for several types is later work: the engine runs several +plans under one key request, then a guest export takes several plans with +their values. G5 allows an SDK to put several types in one request; it does +not require it before the engine can. + +**The guest's value exports have no caller in Go.** The Go SDK seals a whole +value through a declaration (ADR-0008), so `se_encrypt`, `se_decrypt` and the +element exports have no Go caller. They stay while another host of the guest +may need them; they are not a Go path. From 9a77decdaa046e7559ec825c8071b61f626b31a1 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 16:15:57 +1100 Subject: [PATCH 21/22] docs(plans): apply Dan's decisions of 2026-10-05 to the Go SDK design Five decisions from a discussion with Dan Draper, recorded on #1070. The guest returns the EQL value. The data plan names the EQL type as a target, and the guest build that holds the EQL types runs that type's own Rust plan and returns the finished value. Go stores it and assembles nothing, so the EQL encoding lives once, in eql-bindings, and the EQL fixture has nothing to test. Two guest builds: encrypt embeds the one without the EQL types, encrypt/eql embeds the one with them, and a generated file that names an EQL type imports encrypt/eql, so the link is forced by code the user compiles. The size of the larger build is measured before the SDK ships two builds or one. This reverses one bullet of ADR-0007; the amendment and the "EQL v4 types as field targets" section say so. WithGuest joins the Removed list, because with it a program could link the smaller build beside EQL types and fail at run time. The whole struct crosses the binding, both ways. Passthrough fields cross with their values and come back. Heavier on the wire; nothing is rebuilt from parts on either side, so generated code is simpler. Decision 9 of the plan holds. The generated examples send every field and read every field back. One request for several types is deferred, not open. It moves from "Open questions" to a "Deferred" section. se_encrypt and se_decrypt are removed, not kept for a host that does not exist. Each amended ADR opens with a note that a later ADR changed the decision, so a reader of ADR-0004 to ADR-0007 sees the change before the body. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder.md | 91 ++++++++++--------- docs/plans/2026-10-04-plan-builder/README.md | 3 +- .../accounts/account_stash.go | 44 +++++++-- .../contacts/contactstash_stash.go | 8 +- .../individuals/individual_stash.go | 28 ++++-- .../users/user_stash.go | 25 ++--- docs/sdk-design-principles.md | 5 +- ...t-threaded-through-the-declaration-tree.md | 2 + ...edential-guest-for-the-profile-and-auth.md | 2 + ...ender-with-a-slash-and-describe-is-open.md | 2 + ...-through-a-plan-never-a-second-executor.md | 46 ++++++---- .../0008-language-sdk-design-principles.md | 6 +- 12 files changed, 169 insertions(+), 93 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 126127009..b596cf753 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -617,8 +617,21 @@ A library that maps one struct field to one column needs a model for this layout ### EQL types An EQL type is the Go type of one EQL column. +It holds the EQL value as the JSON bytes that Postgres stores. `eql-codegen` writes the package `encrypt/eql` from the EQL catalog. It is a package in the Go module, and not a module of its own. + +The guest builds every EQL value. +For a field with `encrypt_into`, the declaration names the EQL type, and the guest runs that type's own Rust plan. +It returns the finished value, and generated code stores it as it is. +No Go code assembles an EQL value. + +The guest comes in two builds. +`encrypt` embeds the build without the EQL types, and `encrypt/eql` embeds the build with them. +A generated file that names an EQL type imports `encrypt/eql`, so a program with EQL types links the build that has them. +When the program starts, `encrypt/eql` registers its build, and that registration cannot fail. + +The size of the build with the EQL types is measured before the SDK ships two builds or one. The same catalog gives the Rust and TypeScript types, so a type has one name in every language, such as `TextEq`. The JSON type is the exception: its Go name is `JSON`. @@ -840,7 +853,9 @@ The compiler then finds a removed field and a field with a new type, and CI find The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text. It runs none of the package's code. It ignores its own output file when it loads the package, so a stale file does not stop it. + It checks each declaration with the engine: it runs the guest that the SDK embeds, and it holds no copy of the engine's rules. +It asks that guest for the EQL types it holds: each name, its plaintext type, its indexes and its query forms. The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. `stashgen` stops with an error, and writes no file, for each of these: @@ -947,23 +962,23 @@ No function in the SDK or in generated code panics for a declaration, and none h ### What crosses the binding The engine does all encryption, decryption and term derivation. -A field crosses the binding only when its value does. -Generated code sends the engine a declaration and a value for each sealed field and each indexed field. -It sends nothing for a passthrough field, and it copies that value to the generated type itself. -It sends nothing for a field that is left out. +Generated code sends the engine the whole struct: every field with its value, passthrough fields included. +The engine returns the whole struct the same way. +Nothing is rebuilt from parts on either side. +A field that is left out does not cross. So every field the engine is told of has a value, and every value has a field. -The generated file still names every field, so a reviewer reads the whole declaration. -Every call to the guest carries a declaration. -The guest's exports that take a value and no declaration have no caller in Go. +The declaration crosses with the struct. +It names each field's context and indexes, and the EQL type of a field with `encrypt_into`. +The guest returns the finished EQL value for that field. -Generated code assembles an EQL value from the engine's ciphertext and terms. -Two fixtures that both test suites read guard the bytes: +Every call to the guest carries a declaration. +The guest's value exports, `se_encrypt` and `se_decrypt`, are removed. -- **The EQL fixture:** encode in Rust, decode and encode again in Go, and compare. -- **The record fixture:** the Rust chain and generated Go code each open the records that the other encrypted. - Both derive the same bytes for each term. +One fixture that both test suites read guards the bytes. +The Rust chain and generated Go code each open the records that the other encrypted. +Both derive the same bytes for each term. The package `encrypt/gensupport` holds what only generated code calls. No function in it panics. @@ -1013,6 +1028,7 @@ The existing Go package has never been released, so these are removed, not depre - `EncryptedRecord` and `EncryptedField`: a generated type replaces them. - `Cipher.Term`: the query methods of a field entry replace it. - `RecordOption`, `WithPlan` and `ExtendContext`: `Cipher.Extend` replaces the last. +- `WithGuest`: the import of `encrypt/eql` chooses the guest build. - `TermKind`: `Index` replaces it, for generated code. - `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them. - `PlanFromTags`, and every other function that reads `stash` tags at run time. @@ -1135,9 +1151,9 @@ the existing SQL bundle and its `eql_v3_*` domains, which this section does not change. Depends on #971 (`TextEq` / `TextEqQuery` through stack-encrypt in `eql-bindings`, and `Identifier` as a two-segment `Label`). -**The engine never returns an EQL type.** It returns standard outputs: -ciphertexts, terms and passthrough values. Each language's typed layer -assembles EQL types from them: +**The engine returns an EQL type only when a plan names it as a target.** +Otherwise it returns standard outputs: ciphertexts, terms and passthrough +values. Each language names the target its own way: - **Rust**, through the EQL type's own `EncryptFrom` impl, named with the typed verb. A field target may be any `EncryptFrom` type, so an EQL type @@ -1151,23 +1167,25 @@ assembles EQL types from them: .build()?; ``` -- **Go**, through code the generator in #1070 writes. The generated code asks - the guest for the standard outputs of the field's indexes and assembles the - EQL type in Go. +- **Go**, through the data plan. A field names the EQL type as its target, + the guest runs that type's own plan, and the generated code stores the + value the guest returns. (Decided 2026-10-05: the first version of this + section had Go assemble the EQL type from standard outputs.) -So there is **no registry** of EQL types, **no target name in the data -grammar**, and no `Target` trait. The WASI guest stays EQL-free: it runs -data plans and returns standard outputs, exactly as for any other field. A -shape mismatch (the outputs do not fit the EQL type) is a run-time error in -Go, and codegen makes it unreachable in practice. +The data grammar has a **target** field form, exclusive with the output +verbs, and no `Target` trait. A dispatch that `eql-codegen` generates +resolves the name in the guest build that holds the EQL types; the guest build +without them refuses a target name. `stashgen` writes a target name only +beside the import that supplies the dispatch, so that refusal is unreachable +in practice. An EQL type is still a target that wraps an index's output; the index itself stays in stack-encrypt. The JSON type is the clearest case: the `Json` index produces the searchable document, and EQL's JSON type frames it. -**The cost: the EQL byte encoding lives twice**, in `eql-bindings` (Rust) and -in a Go EQL package. A standing cross-language fixture guards it: encode in -Rust, decode and re-encode in Go, and compare the bytes. +**The cost: a second guest build**, with the EQL types in it. Its size is +measured before the SDK ships two builds or one. The EQL encoding lives once, +in `eql-bindings`. Match and JSON index options need a wire form first (Additions, item 7): the Go side derives its terms from a data plan, and a dropped option there is a @@ -1213,27 +1231,16 @@ Then: - Lock and audit context themselves; this plan only leaves them a place. - Named term accessors on `Encrypted` (decision 7). -## Open questions +## Deferred - **One request for several types.** - One call covers one type today, because the guest takes one declaration in a call. + One call covers one type, because the guest takes one declaration in a call. One request for several types needs the engine to run several plans under one key request. It also needs a guest export that takes several declarations, each with its values. The Go call for it is designed after that work. -- **The history in this plan.** - "Why the first draft was dropped" and the rejected names under "Decisions" are history. - The design principles keep history out of a design document, so those parts move to ADR-0007. - Dan Draper owns that move. -- **Query building in Go.** - The SDK gives the values for a search, and the program writes the SQL. - A design for building that SQL is separate work. -- **A `go vet` check.** - It reports a struct with sealed fields that a program prints, and an `Encrypt` call inside a loop. - Its design is separate work. -- **A change to a declaration over time.** - This design covers one version of a declaration. - Reading data that an older declaration wrote needs its own design. - A policy has `Identity` for a column with a new name, and tags have no word for it yet. + +## Open questions + - **Converging the TypeScript schema builder onto the plan grammar**, so `@cipherstash/stack` stops being a second engine beside stack-encrypt. Out of scope here; recorded so a TS binding does not grow an executor. diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index 7514c7587..b962cb61d 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -39,7 +39,8 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces | A generated file from another version does not compile | Run. A file that names an unknown version constant fails `go build`. | | The files `stashgen` writes | Not run. `stashgen` does not exist, and the five `_stash.go` files are written by hand. | | The SDK can be built with these signatures | Not run. | -| The record fixture and the EQL fixture | Not run. Neither fixture exists. | +| The record fixture | Not run. It does not exist. | +| The guest returns an EQL value for a field with `encrypt_into` | Not run. The target form and the dispatch do not exist. | | `stashgen` checks a declaration with the embedded guest | Not run. | | The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. | | The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go index 5e3a04b17..fcafb3e60 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -5,6 +5,7 @@ package accounts import ( "context" "log/slog" + "time" "github.com/cipherstash/stack/languages/golang/encrypt" "github.com/cipherstash/stack/languages/golang/encrypt/eql" @@ -56,7 +57,7 @@ var declaration = gensupport.Declare("accounts"). Passthrough("created_at"). Passthrough("updated_at"). Passthrough("deleted_at"). - EncryptIndex("email", encrypt.Equality). + EncryptInto("email", "TextEq"). Omit("token") var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ @@ -64,20 +65,45 @@ var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ Declaration: declaration, Unexported: []string{"cache"}, Source: func(v Account) gensupport.Values { - return gensupport.Values{"email": v.Email} + return gensupport.Values{ + "id": v.ID, + "created_at": v.CreatedAt, + "updated_at": v.UpdatedAt, + "deleted_at": v.DeletedAt, + "email": v.Email, + } }, Seal: func(v Account, rec gensupport.Record) EncryptedAccount { - return EncryptedAccount{Model: v.Model, Email: eql.NewTextEq(rec["email"])} + return EncryptedAccount{Model: v.Model, Email: eql.TextEq(rec["email"].EQL)} }, Open: func(e EncryptedAccount) gensupport.Record { - return gensupport.Record{"email": e.Email.Outputs()} + return gensupport.Record{ + "id": {Value: e.ID}, + "created_at": {Value: e.CreatedAt}, + "updated_at": {Value: e.UpdatedAt}, + "deleted_at": {Value: e.DeletedAt}, + "email": {EQL: e.Email}, + } }, Value: func(e EncryptedAccount, vals gensupport.Values) (Account, error) { - email, err := gensupport.Get[string](vals, "email") - if err != nil { + var v Account + var err error + if v.ID, err = gensupport.Get[uint](vals, "id"); err != nil { + return Account{}, err + } + if v.CreatedAt, err = gensupport.Get[time.Time](vals, "created_at"); err != nil { + return Account{}, err + } + if v.UpdatedAt, err = gensupport.Get[time.Time](vals, "updated_at"); err != nil { + return Account{}, err + } + if v.DeletedAt, err = gensupport.Get[gorm.DeletedAt](vals, "deleted_at"); err != nil { + return Account{}, err + } + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return Account{}, err } - return Account{Model: e.Model, Email: email}, nil + return v, nil }, }) @@ -101,10 +127,10 @@ type EmailField struct { func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewTextEq(out), err + return eql.TextEq(out.EQL), err } func (f EmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewTextEqQuery(out), err + return eql.TextEqQuery(out.EQL), err } diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index 2f3e05233..15971bc0a 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -63,7 +63,7 @@ var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ Declaration: declaration, PrintsPlaintext: true, Source: func(v crm.Contact) gensupport.Values { - return gensupport.Values{"email": v.Email, "phone_number": v.PhoneNumber} + return gensupport.Values{"id": v.ID, "email": v.Email, "phone_number": v.PhoneNumber} }, Seal: func(v crm.Contact, rec gensupport.Record) EncryptedContact { email, phone := rec["email"], rec["phone_number"] @@ -75,13 +75,17 @@ var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ }, Open: func(e EncryptedContact) gensupport.Record { return gensupport.Record{ + "id": {Value: e.ID}, "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, "phone_number": {Ciphertext: e.PhoneNumber.Ciphertext, Equality: e.PhoneNumber.Equality}, } }, Value: func(e EncryptedContact, vals gensupport.Values) (crm.Contact, error) { - v := crm.Contact{ID: e.ID} + var v crm.Contact var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return crm.Contact{}, err + } if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return crm.Contact{}, err } diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index d0d087462..a949206eb 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -52,7 +52,7 @@ var declaration = gensupport.Declare("individuals"). Passthrough("id"). Encrypt("name"). EncryptIndex("email", encrypt.Equality, encrypt.Match()). - EncryptIndex("medicare_number", encrypt.Equality). + EncryptInto("medicare_number", "TextEq"). Passthrough("nickname") var codec = gensupport.New(gensupport.Generated[*pb.Individual, EncryptedIndividual]{ @@ -60,7 +60,13 @@ var codec = gensupport.New(gensupport.Generated[*pb.Individual, EncryptedIndivid Declaration: declaration, PrintsPlaintext: true, Source: func(v *pb.Individual) gensupport.Values { - return gensupport.Values{"name": v.GetName(), "email": v.GetEmail(), "medicare_number": v.GetMedicareNo()} + return gensupport.Values{ + "id": v.GetId(), + "name": v.GetName(), + "email": v.GetEmail(), + "medicare_number": v.GetMedicareNo(), + "nickname": v.GetNickname(), + } }, Seal: func(v *pb.Individual, rec gensupport.Record) EncryptedIndividual { email := rec["email"] @@ -68,20 +74,28 @@ var codec = gensupport.New(gensupport.Generated[*pb.Individual, EncryptedIndivid Id: v.GetId(), Name: EncryptedIndividualName{Ciphertext: rec["name"].Ciphertext}, Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - MedicareNo: eql.NewTextEq(rec["medicare_number"]), + MedicareNo: eql.TextEq(rec["medicare_number"].EQL), Nickname: v.GetNickname(), } }, Open: func(e EncryptedIndividual) gensupport.Record { return gensupport.Record{ + "id": {Value: e.Id}, + "nickname": {Value: e.Nickname}, "name": {Ciphertext: e.Name.Ciphertext}, "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality, Match: e.Email.Match}, - "medicare_number": e.MedicareNo.Outputs(), + "medicare_number": {EQL: e.MedicareNo}, } }, Value: func(e EncryptedIndividual, vals gensupport.Values) (*pb.Individual, error) { - v := &pb.Individual{Id: e.Id, Nickname: e.Nickname} + v := &pb.Individual{} var err error + if v.Id, err = gensupport.Get[int64](vals, "id"); err != nil { + return nil, err + } + if v.Nickname, err = gensupport.Get[string](vals, "nickname"); err != nil { + return nil, err + } if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { return nil, err } @@ -148,10 +162,10 @@ type MedicareNoField struct { func (f MedicareNoField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewTextEq(out), err + return eql.TextEq(out.EQL), err } func (f MedicareNoField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewTextEqQuery(out), err + return eql.TextEqQuery(out.EQL), err } diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index b184ac714..3711ca7b7 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -44,8 +44,8 @@ type userShape struct { var declaration = gensupport.Declare("users"). Passthrough("id"). - EncryptIndex("email", encrypt.Equality). - EncryptIndex("name", encrypt.Equality). + EncryptInto("email", "TextEq"). + EncryptInto("name", "TextEq"). Omit("internal") var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ @@ -53,21 +53,24 @@ var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ Declaration: declaration, PrintsPlaintext: true, Source: func(v User) gensupport.Values { - return gensupport.Values{"email": v.Email, "name": v.Name} + return gensupport.Values{"id": v.ID, "email": v.Email, "name": v.Name} }, Seal: func(v User, rec gensupport.Record) EncryptedUser { return EncryptedUser{ ID: v.ID, - Email: eql.NewTextEq(rec["email"]), - Name: eql.NewTextEq(rec["name"]), + Email: eql.TextEq(rec["email"].EQL), + Name: eql.TextEq(rec["name"].EQL), } }, Open: func(e EncryptedUser) gensupport.Record { - return gensupport.Record{"email": e.Email.Outputs(), "name": e.Name.Outputs()} + return gensupport.Record{"id": {Value: e.ID}, "email": {EQL: e.Email}, "name": {EQL: e.Name}} }, Value: func(e EncryptedUser, vals gensupport.Values) (User, error) { - v := User{ID: e.ID} + var v User var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return User{}, err + } if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { return User{}, err } @@ -103,12 +106,12 @@ type EmailField struct { func (f EmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewTextEq(out), err + return eql.TextEq(out.EQL), err } func (f EmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewTextEqQuery(out), err + return eql.TextEqQuery(out.EQL), err } type NameField struct { @@ -117,10 +120,10 @@ type NameField struct { func (f NameField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { out, err := f.field.Encrypt(ctx, c, v) - return eql.NewTextEq(out), err + return eql.TextEq(out.EQL), err } func (f NameField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { out, err := f.field.Query(ctx, c, v) - return eql.NewTextEqQuery(out), err + return eql.TextEqQuery(out.EQL), err } diff --git a/docs/sdk-design-principles.md b/docs/sdk-design-principles.md index b3705691b..b9cb24e6c 100644 --- a/docs/sdk-design-principles.md +++ b/docs/sdk-design-principles.md @@ -30,9 +30,8 @@ All encryption, decryption and term derivation happens in the engine. An SDK declares what to do and sends that across the binding as data. An SDK does not have its own loop over fields, its own batching, or its own sealing. -- A field crosses the binding only when its value does: an SDK sends a field's declaration with the field's value. -- An SDK can keep a field on its own side when the engine computes nothing from it. - It then sends no declaration and no value for that field. +- An SDK sends the whole value and the whole declaration, and the engine returns the whole value. + Nothing is rebuilt from parts on either side. - An SDK tool that checks a declaration asks the engine, and holds no copy of the engine's rules. - An SDK can assemble a wire format in the host language only when a cross-language test compares the bytes. diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index 99acd0656..19056b7ae 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -318,6 +318,8 @@ target layer on the write side. ## Amended 2026-10-06 (#1070) +A later ADR changed this decision: ADR-0008, the language SDK principles, +and the Go SDK design it governs. This amendment records what changed. G1 to G8 and Go-1 to Go-13 name the principles in `docs/sdk-design-principles.md`, general and Go. diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md index 45a71fdb6..d5cb04880 100644 --- a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -251,6 +251,8 @@ directory, `stackauth.OpenWithoutProfile` gives it none. ## Amendment (2026-10-06, #1070): the module at `languages/golang`, the packages `encrypt` and `auth` +A later ADR changed this decision: ADR-0008, the language SDK principles, +and the Go SDK design it governs. This amendment records what changed. G1 to G8 and Go-1 to Go-13 name the principles in `docs/sdk-design-principles.md`, general and Go. diff --git a/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md b/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md index ad1548f04..888ef7c11 100644 --- a/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md +++ b/packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md @@ -122,6 +122,8 @@ decision are unchanged. ## Amended 2026-10-06 (#1070) +A later ADR changed this decision: ADR-0008, the language SDK principles, +and the Go SDK design it governs. This amendment records what changed. G1 to G8 and Go-1 to Go-13 name the principles in `docs/sdk-design-principles.md`, general and Go. diff --git a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md index ce96c3cf8..15fbe3acd 100644 --- a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md +++ b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md @@ -12,10 +12,12 @@ extends: ADR-0003, ADR-0004 > (per language, from standard outputs), and the consequence that the EQL > encoding lives twice. > -> **Amended 2026-10-06**, by #1070. The decision is unchanged. Amended: the -> words binding and language SDK, what crosses the binding from Go, the proof -> of the lowering, where the generator gets the engine's rules, and what the -> guest takes in one call. +> **Amended 2026-10-06**, by #1070. One bullet of the decision changes: the +> data grammar names an EQL type as a target, and the guest build that holds +> the EQL types returns the finished value. Also amended: the words binding +> and language SDK, what crosses the binding from Go, the proof of the +> lowering, where the generator gets the engine's rules, what the guest takes +> in one call, and the value exports. Stack Encrypt has one execution engine: the `Encryption` and `Decryption` descriptions in `target/` and the batched `Pending` they produce. ADR-0003 @@ -119,7 +121,9 @@ The design history, the names rejected on the way and the sequencing are in ## Amended 2026-10-06 (#1070) -G1 to G8 and Go-1 to Go-13 name the principles in +A later ADR changed parts of this decision: ADR-0008, the language SDK +principles, and the Go SDK design it governs. This amendment records what +changed. G1 to G8 and Go-1 to Go-13 name the principles in `docs/sdk-design-principles.md`, general and Go. ADR-0008 fixes two words this ADR used as one. A **binding** is the WASI or @@ -130,13 +134,12 @@ crosses it. The Go SDK is struct tags, a generator and generated code, and its users never see a plan (Go-7). Each "binding" above that names Go reads as the Go SDK's generated code. -**A field crosses the binding only when its value does.** The first Go design -sent the full declaration and skipped passthrough values, which contradicted -decision 9 of the plan: every plan field is present in the value. Generated Go -code now sends a declaration and a value for each sealed and each indexed -field, and nothing for a passthrough or omitted field. The data grammar does -not change. The generated file still names every field, so a reviewer reads -the whole declaration (G1, Go-10). +**The whole struct crosses the binding, both ways.** Generated Go code sends +every field with its value, passthrough fields included, and the engine +returns the whole struct the same way. That is heavier on the wire than +sending sealed fields alone. It needs no reconstitution on either side, so the +code stays simpler. Decision 9 of the plan holds: every plan field is present +in the value, and the data grammar does not change (G1, Go-10). **The record fixture is the proof of the lowering.** Sequencing rested on Go's `plantest.Golden` snapshots not changing, and the Go SDK removes the package @@ -159,7 +162,18 @@ plans under one key request, then a guest export takes several plans with their values. G5 allows an SDK to put several types in one request; it does not require it before the engine can. -**The guest's value exports have no caller in Go.** The Go SDK seals a whole -value through a declaration (ADR-0008), so `se_encrypt`, `se_decrypt` and the -element exports have no Go caller. They stay while another host of the guest -may need them; they are not a Go path. +**The guest's value exports are removed.** The Go SDK seals a whole value +through a declaration (ADR-0008), so `se_encrypt` and `se_decrypt` have no +caller. They are removed rather than kept for a host that does not exist. + +**The guest returns an EQL value when a plan names the type.** The decision +above said no registry, no target name in the data grammar, and a guest that +stays EQL-free. That is reversed. The data grammar gains a target field form, +exclusive with the output verbs. A dispatch that `eql-codegen` generates +resolves the name, in a guest build that holds the EQL types; the build +without them refuses a target name. Go stores the value the guest returns and +has no EQL encoder, so the "lives twice" consequence below no longer applies +to Go (G1: one engine). The reasons recorded against this on 2026-10-04 were +guest size and an EQL-free guest for programs that never store into EQL. Two +guest builds answer the second, and the first is measured before the SDK ships +one build or two (G7). diff --git a/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md index 027b359ae..8a18675e4 100644 --- a/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md +++ b/packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md @@ -45,8 +45,10 @@ When two principles disagree, the document gives the order to apply them in. A whole value is sealed through a declaration, so it always has a declared context. A caller's context appends to the declared one. ADR-0003 keeps the cipher-directed path open for Rust, and that does not change. -- One request for several types needs work in the engine and in the guest first. - Until then, one call covers one type. +- One request for several types is deferred. + One call covers one type until the engine and the guest take several plans in one call. +- The Go SDK has no EQL encoder. + The guest runs an EQL type's own plan and returns the finished value. - Each language SDK is its own design. The principles say what must match between them: the bytes, the words for engine behaviour, and fail-closed behaviour. - Three questions are open, and the principles document lists them. From 48342ab5577ed80efc2889eb587f0e654eb16365 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 16:38:11 +1100 Subject: [PATCH 22/22] docs(plans): generated examples build the encrypted type from what the engine returned The plan says the whole struct crosses the binding both ways and nothing is rebuilt from parts on either side. The generated examples still copied passthrough fields from the Go value into the encrypted type, so one side rebuilt. Seal now takes only the record the engine returned, reads each passthrough field from it, and returns an error for a value of the wrong type rather than asserting. The README row that said generated code assembles EQL types says the guest returns them. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi --- docs/plans/2026-10-04-plan-builder/README.md | 2 +- .../accounts/account_stash.go | 19 +++++++++++++++-- .../contacts/contactstash_stash.go | 10 ++++++--- .../documents/document_stash.go | 4 ++-- .../individuals/individual_stash.go | 21 ++++++++++++------- .../users/user_stash.go | 10 ++++++--- 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/docs/plans/2026-10-04-plan-builder/README.md b/docs/plans/2026-10-04-plan-builder/README.md index b962cb61d..b67641fe4 100644 --- a/docs/plans/2026-10-04-plan-builder/README.md +++ b/docs/plans/2026-10-04-plan-builder/README.md @@ -43,7 +43,7 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces | The guest returns an EQL value for a field with `encrypt_into` | Not run. The target form and the dispatch do not exist. | | `stashgen` checks a declaration with the embedded guest | Not run. | | The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. | -| The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | +| The EQL types, and the value the guest returns for them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. | | The steps in "Use the SDK" | Not run. Nobody has followed them. | ## Generate the protobuf package diff --git a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go index fcafb3e60..11ce6832c 100644 --- a/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go +++ b/docs/plans/2026-10-04-plan-builder/accounts/account_stash.go @@ -73,8 +73,23 @@ var codec = gensupport.New(gensupport.Generated[Account, EncryptedAccount]{ "email": v.Email, } }, - Seal: func(v Account, rec gensupport.Record) EncryptedAccount { - return EncryptedAccount{Model: v.Model, Email: eql.TextEq(rec["email"].EQL)} + Seal: func(rec gensupport.Record) (EncryptedAccount, error) { + var e EncryptedAccount + var err error + if e.ID, err = gensupport.Passthrough[uint](rec, "id"); err != nil { + return EncryptedAccount{}, err + } + if e.CreatedAt, err = gensupport.Passthrough[time.Time](rec, "created_at"); err != nil { + return EncryptedAccount{}, err + } + if e.UpdatedAt, err = gensupport.Passthrough[time.Time](rec, "updated_at"); err != nil { + return EncryptedAccount{}, err + } + if e.DeletedAt, err = gensupport.Passthrough[gorm.DeletedAt](rec, "deleted_at"); err != nil { + return EncryptedAccount{}, err + } + e.Email = eql.TextEq(rec["email"].EQL) + return e, nil }, Open: func(e EncryptedAccount) gensupport.Record { return gensupport.Record{ diff --git a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go index 15971bc0a..a8f7f137d 100644 --- a/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go +++ b/docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go @@ -65,13 +65,17 @@ var codec = gensupport.New(gensupport.Generated[crm.Contact, EncryptedContact]{ Source: func(v crm.Contact) gensupport.Values { return gensupport.Values{"id": v.ID, "email": v.Email, "phone_number": v.PhoneNumber} }, - Seal: func(v crm.Contact, rec gensupport.Record) EncryptedContact { + Seal: func(rec gensupport.Record) (EncryptedContact, error) { + id, err := gensupport.Passthrough[int64](rec, "id") + if err != nil { + return EncryptedContact{}, err + } email, phone := rec["email"], rec["phone_number"] return EncryptedContact{ - ID: v.ID, + ID: id, Email: EncryptedContactEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, PhoneNumber: EncryptedContactPhoneNumber{Ciphertext: phone.Ciphertext, Equality: phone.Equality}, - } + }, nil }, Open: func(e EncryptedContact) gensupport.Record { return gensupport.Record{ diff --git a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go index 1d483c409..7d22c1e51 100644 --- a/docs/plans/2026-10-04-plan-builder/documents/document_stash.go +++ b/docs/plans/2026-10-04-plan-builder/documents/document_stash.go @@ -47,8 +47,8 @@ var codec = gensupport.New(gensupport.Generated[Document, EncryptedDocument]{ Source: func(v Document) gensupport.Values { return gensupport.Values{gensupport.OpaqueField: map[string]any{"title": v.Title, "body": v.Body, "tags": v.Tags}} }, - Seal: func(v Document, rec gensupport.Record) EncryptedDocument { - return EncryptedDocument{Sealed: rec[gensupport.OpaqueField].Ciphertext} + Seal: func(rec gensupport.Record) (EncryptedDocument, error) { + return EncryptedDocument{Sealed: rec[gensupport.OpaqueField].Ciphertext}, nil }, Open: func(e EncryptedDocument) gensupport.Record { return gensupport.Record{gensupport.OpaqueField: {Ciphertext: e.Sealed}} diff --git a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go index a949206eb..c076036ad 100644 --- a/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go +++ b/docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go @@ -68,15 +68,20 @@ var codec = gensupport.New(gensupport.Generated[*pb.Individual, EncryptedIndivid "nickname": v.GetNickname(), } }, - Seal: func(v *pb.Individual, rec gensupport.Record) EncryptedIndividual { - email := rec["email"] - return EncryptedIndividual{ - Id: v.GetId(), - Name: EncryptedIndividualName{Ciphertext: rec["name"].Ciphertext}, - Email: EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match}, - MedicareNo: eql.TextEq(rec["medicare_number"].EQL), - Nickname: v.GetNickname(), + Seal: func(rec gensupport.Record) (EncryptedIndividual, error) { + var e EncryptedIndividual + var err error + if e.Id, err = gensupport.Passthrough[int64](rec, "id"); err != nil { + return EncryptedIndividual{}, err } + if e.Nickname, err = gensupport.Passthrough[string](rec, "nickname"); err != nil { + return EncryptedIndividual{}, err + } + email := rec["email"] + e.Name = EncryptedIndividualName{Ciphertext: rec["name"].Ciphertext} + e.Email = EncryptedIndividualEmail{Ciphertext: email.Ciphertext, Equality: email.Equality, Match: email.Match} + e.MedicareNo = eql.TextEq(rec["medicare_number"].EQL) + return e, nil }, Open: func(e EncryptedIndividual) gensupport.Record { return gensupport.Record{ diff --git a/docs/plans/2026-10-04-plan-builder/users/user_stash.go b/docs/plans/2026-10-04-plan-builder/users/user_stash.go index 3711ca7b7..83ed60d14 100644 --- a/docs/plans/2026-10-04-plan-builder/users/user_stash.go +++ b/docs/plans/2026-10-04-plan-builder/users/user_stash.go @@ -55,12 +55,16 @@ var codec = gensupport.New(gensupport.Generated[User, EncryptedUser]{ Source: func(v User) gensupport.Values { return gensupport.Values{"id": v.ID, "email": v.Email, "name": v.Name} }, - Seal: func(v User, rec gensupport.Record) EncryptedUser { + Seal: func(rec gensupport.Record) (EncryptedUser, error) { + id, err := gensupport.Passthrough[int64](rec, "id") + if err != nil { + return EncryptedUser{}, err + } return EncryptedUser{ - ID: v.ID, + ID: id, Email: eql.TextEq(rec["email"].EQL), Name: eql.TextEq(rec["name"].EQL), - } + }, nil }, Open: func(e EncryptedUser) gensupport.Record { return gensupport.Record{"id": {Value: e.ID}, "email": {EQL: e.Email}, "name": {EQL: e.Name}}