Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions DECISION_LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# DECISION LOG

## 2026-04-14: Ship encrypted backup as a subcommand; leave restore as plain `age -d`

### Context

The `private_memory` wiring stores sensitive content (therapy, health, relationships). Telemetry already has a privacy-strict mode, but the main memory DB on disk remains plaintext. The threat model of "laptop lost spento" is covered by FileVault/BitLocker/LUKS at the OS level; the gap is cloud backup: iCloud Drive / Google Drive / Dropbox all corrupt live SQLite DBs (WAL race with sync client). Cross-platform encryption-at-rest on the live DB would require SQLCipher (CGO) and cross-platform keychain integration — both violate the pure-Go single-binary invariant for marginal gain over OS crypto.

### Decision

Add a `workmem backup` subcommand that writes an age-encrypted snapshot of the global memory DB. The snapshot is consistent (produced via `VACUUM INTO`, not a raw copy), the plaintext intermediate never leaves the temp directory, and the output uses `0600` permissions. Decryption is not wrapped by the CLI — users run `age -d -i identity.txt backup.age > memory.db` manually.

### Rationale

- `filippo.io/age` is pure Go — preserves `CGO_ENABLED=0` and the single-binary product direction.
- `VACUUM INTO` produces a driver-agnostic consistent snapshot; no dependence on modernc or SQLite-private backup APIs.
- End-to-end encryption with a user-held key gives cloud-sync safety (the `.age` file is useless without the identity) without claiming to solve problems the OS already solves (live-disk encryption).
- Asymmetric-only (no passphrase) keeps the operator UX honest: key lives in a file, backup is unattended, no prompts.
- Not wrapping restore keeps the CLI honest about its scope — restore is context-dependent (stop server? atomic swap? merge?) and those choices should be the user's, not ours.

### Alternatives considered

- **Encryption at rest on the live DB via SQLCipher + cross-platform keychain.** Rejected: requires CGO, cross-platform keychain gymnastics (macOS Keychain, Windows Credential Manager, Linux Secret Service with headless fallback), and the threat model above the OS crypto layer is narrow (laptop awake, unlocked, and stolen — chain of custody the app cannot fully defend anyway).
- **Litestream / WAL streaming replication to S3.** Rejected for v1: overkill for a personal tool where daily backup is enough, and introduces an operational dependency (object store + credentials) that clashes with "one binary, one file" positioning. Might reconsider later for power users.
- **Passphrase-based symmetric encryption.** Rejected: worse UX for unattended runs, and still funnels into age's file format anyway — if a user wants symmetric, `age -p` on the output file is a one-liner.
- **Include project-scoped DBs automatically.** Rejected: project DBs belong to workspaces, not to the user's top-level knowledge. Auto-including them couples backup to filesystem scanning and makes the unit of restore ambiguous. A `backup` invocation per workspace is explicit.
- **Include telemetry.db in the snapshot.** Rejected: telemetry is operational, rebuildable, and has a different lifecycle than knowledge. Mixing them also risks leaking telemetry via recall if paths cross.

## 2026-04-14: Use the official Go MCP SDK for transport

### Context
Expand Down
14 changes: 13 additions & 1 deletion IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,16 @@ Brief description. **Gate:** release artifacts exist for major OS targets and in
- [ ] Draft Homebrew strategy
- [ ] Validate install path with fresh-machine assumptions

**On Step Gate (all items [x]):** trigger release readiness review.
**On Step Gate (all items [x]):** trigger release readiness review.

### Step 3.4: Encrypted backup command [✅]

Ship a `workmem backup` subcommand that produces an age-encrypted snapshot of the global memory DB, taken via `VACUUM INTO` for consistency and streamed through `filippo.io/age` without any CGO additions. **Gate:** round-trip test proves the encrypted snapshot decrypts back to a readable SQLite database matching the source.

- [x] Add `filippo.io/age` dependency (pure Go, preserves `CGO_ENABLED=0`)
- [x] Implement `internal/backup` package: `Run(ctx, sourceDB, destPath, recipients)` with VACUUM INTO + age encryption, plus `ParseRecipients` accepting both raw `age1…` keys and recipients-file paths
- [x] Unit tests: round-trip, missing source, zero recipients, unwritable dest, invalid recipients
- [x] Wire `backup` subcommand in `cmd/workmem/main.go` with `--to`, `--age-recipient` (repeatable), `--db`, `--env-file`
- [x] README section documenting usage and manual `age -d` restore

**On Step Gate (all items [x]):** trigger correctness review focused on crypto wiring and VACUUM INTO error paths.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,28 @@ Don't remember: transient tasks, code snippets, things already in docs/git.

SQLite with WAL mode. Tables: `entities`, `observations`, `relations`, `events`, `memory_fts` (FTS5). Schema created automatically. Soft-delete via `deleted_at` tombstones — forgotten facts are excluded from retrieval but remain in the database.

## Backup

Produce an end-to-end encrypted snapshot with the `backup` subcommand. The snapshot is taken via `VACUUM INTO` (consistent, no lock on the live DB) and encrypted with [age](https://age-encryption.org). The plaintext intermediate never leaves the temp directory; the output is written with `0600` permissions.

```bash
# single recipient
workmem backup --to backup.age --age-recipient age1yourpubkey...

# multiple recipients and/or a recipients file
workmem backup --to backup.age \
--age-recipient age1alpha... \
--age-recipient /path/to/recipients.txt
```

Restore with the standard age CLI:

```bash
age -d -i my-identity.txt backup.age > memory.db
```

Only the global memory DB is included. Project-scoped DBs live in their own workspaces and are out of scope. Telemetry data (if enabled) is operational and not included — rebuild freely.

## Design principles

- **Stupidity of use, solidity of backend.** The model doesn't think about memory. It just calls tools. The ranking, decay, and retrieval happen behind the curtain.
Expand Down
70 changes: 67 additions & 3 deletions cmd/workmem/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/signal"
"syscall"

"workmem/internal/backup"
"workmem/internal/dotenv"
"workmem/internal/mcpserver"
"workmem/internal/store"
Expand All @@ -24,6 +25,8 @@ func main() {
runMCP(os.Args[2:])
case os.Args[1] == "sqlite-canary":
runSQLiteCanary(os.Args[2:])
case os.Args[1] == "backup":
runBackup(os.Args[2:])
case os.Args[1][0] == '-':
// no subcommand, treat remaining args as flags for the default (serve) command
runMCP(os.Args[1:])
Expand All @@ -34,6 +37,61 @@ func main() {
}
}

// recipientFlag collects repeatable --age-recipient arguments.
type recipientFlag []string

func (r *recipientFlag) String() string { return fmt.Sprint([]string(*r)) }
func (r *recipientFlag) Set(value string) error {
*r = append(*r, value)
return nil
}

func runBackup(args []string) {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
dbPath := fs.String("db", "", "path to the SQLite database file (defaults to MEMORY_DB_PATH or binary-relative memory.db)")
envFile := fs.String("env-file", "", "path to a .env file to load before running (process env wins over file values)")
to := fs.String("to", "", "destination file for the encrypted snapshot (required)")
var recipients recipientFlag
fs.Var(&recipients, "age-recipient", "age recipient public key (age1...) or path to a recipients file; repeatable, at least one required")
_ = fs.Parse(args)

loadEnvFile(*envFile)

if *to == "" {
fmt.Fprintln(os.Stderr, "backup: --to <path> is required")
os.Exit(2)
}
if len(recipients) == 0 {
fmt.Fprintln(os.Stderr, "backup: at least one --age-recipient is required")
os.Exit(2)
}

sourceDB, err := mcpserver.ResolveDBPath(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "backup: resolve source db: %v\n", err)
os.Exit(1)
}

parsed, err := backup.ParseRecipients(recipients)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backup.ParseRecipients takes a []string, but recipients here is a named slice type (recipientFlag). As written, backup.ParseRecipients(recipients) won’t compile; convert it explicitly (e.g., to the underlying []string) or change ParseRecipients to accept the named type/interface.

Suggested change
parsed, err := backup.ParseRecipients(recipients)
parsed, err := backup.ParseRecipients([]string(recipients))

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does compile — Go's assignability rules allow passing a named slice type where the parameter type is the underlying unnamed slice type. From the spec: "A value x of type V is assignable to a variable of type T if V and T have identical underlying types and at least one of V or T is not a named type." Here V is recipientFlag (named, underlying []string) and T is []string (unnamed), so the call is valid without an explicit conversion. Empirical proof: Test (ubuntu-latest / macos-latest / windows-latest) and all five cross-build jobs on this branch's HEAD are green, and go build ./... succeeds locally. I'd rather keep the call site natural than add a cosmetic conversion.

if err != nil {
fmt.Fprintf(os.Stderr, "backup: %v\n", err)
os.Exit(1)
}

// Signal-aware context so Ctrl+C (SIGINT) and SIGTERM can interrupt a
// long VACUUM cleanly. The backup package threads the context into
// sql.ExecContext, so cancellation propagates to the SQLite driver.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := backup.Run(ctx, sourceDB, *to, parsed); err != nil {
fmt.Fprintf(os.Stderr, "backup: %v\n", err)
os.Exit(1)
}

fmt.Printf("backup: wrote encrypted snapshot of %s to %s (%d recipient(s))\n", sourceDB, *to, len(parsed))
}

func runMCP(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
dbPath := fs.String("db", "", "path to the SQLite database file")
Expand Down Expand Up @@ -101,8 +159,14 @@ func printUsage() {
fmt.Fprintf(os.Stderr, " workmem <command> [flags]\n\n")
fmt.Fprintf(os.Stderr, "commands:\n")
fmt.Fprintf(os.Stderr, " serve run the MCP server over stdio (default)\n")
fmt.Fprintf(os.Stderr, " sqlite-canary prove schema init, FTS insert/match/delete, and persistence\n\n")
fmt.Fprintf(os.Stderr, "flags (all commands):\n")
fmt.Fprintf(os.Stderr, " sqlite-canary prove schema init, FTS insert/match/delete, and persistence\n")
fmt.Fprintf(os.Stderr, " backup write an age-encrypted snapshot of memory.db\n\n")
fmt.Fprintf(os.Stderr, "flags (serve, sqlite-canary, backup):\n")
fmt.Fprintf(os.Stderr, " -db <path> path to the SQLite database file\n")
fmt.Fprintf(os.Stderr, " -env-file <path> load variables from a .env file (process env takes precedence)\n")
fmt.Fprintf(os.Stderr, " -env-file <path> load variables from a .env file (process env takes precedence)\n\n")
fmt.Fprintf(os.Stderr, "backup flags:\n")
fmt.Fprintf(os.Stderr, " -to <path> destination file for the encrypted snapshot (required)\n")
fmt.Fprintf(os.Stderr, " -age-recipient <key> age recipient (age1... or file path), repeatable, at least one required\n\n")
fmt.Fprintf(os.Stderr, "restore a backup with the age CLI:\n")
fmt.Fprintf(os.Stderr, " age -d -i <identity-file> <backup.age> > memory.db\n")
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ module workmem
go 1.26.1

require (
filippo.io/age v1.3.1
github.com/modelcontextprotocol/go-sdk v1.5.0
modernc.org/sqlite v1.39.1
)

require (
filippo.io/hpke v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand All @@ -17,6 +19,7 @@ require (
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
Expand All @@ -24,6 +30,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
Expand Down
Loading
Loading