carlos / platform Public

edge asset cache plan 1 task 0: copy record EAC-1 to EAC-22 (all pending) and its gate

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 38819c5d879c8c11d41011a83b4a9bc85ade3241 parent af1fb3e
2 files changed, +160 −0
  • approved_asset_cache_copy_test.go +72 −0
  • docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md +88 −0
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go
new file mode 100644
index 000000000..2e9619b51
--- /dev/null
+++ b/approved_asset_cache_copy_test.go
@@ -0,0 +1,72 @@
+// approved_asset_cache_copy_test.go
+package main
+
+import (
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// The copy gate of the edge asset cache plans (spec 2026-10-24). The record
+// is docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md. It reuses
+// the moves gate's helpers (parseCopyTable, checkMovesInventory,
+// checkMovesNoEmDash, checkRunBuilt, builtRows, checkRunApplied), so the
+// parser, the em dash rule and the "text is in the file" match are the
+// record formats' already.
+//
+// - TestAssetCacheCopyInventory: the record names exactly
+// assetCacheCopyIDs, and no row holds an em dash;
+// - TestAssetCacheCopyBuiltRowsAreApprovedAndPresent: an id in
+// assetCacheCopyBuilt is an approved row, and its text is in the file it
+// names. No pending string reaches code or docs.
+const assetCacheCopyRecordPath = "docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md"
+
+var assetCacheCopyIDPattern = regexp.MustCompile(`^EAC-[0-9]+$`)
+
+// assetCacheCopyIDs is the record's inventory, pinned: EAC-1 to EAC-22.
+// Plan 3 Task 0 extends it to EAC-24 (its two docs rows).
+var assetCacheCopyIDs = func() []string {
+ ids := make([]string, 0, 22)
+ for i := 1; i <= 22; i++ {
+ ids = append(ids, "EAC-"+strconv.Itoa(i))
+ }
+ return ids
+}()
+
+// assetCacheCopyBuilt is every id whose text the code or docs carry. The task
+// that writes a row's text adds its id here in the same commit, once Paul has
+// approved the row. It starts empty: the copy review comes first.
+var assetCacheCopyBuilt = []string{}
+
+func mustParseAssetCacheCopyRecord(t *testing.T) []copyRow {
+ t.Helper()
+ raw, err := os.ReadFile(assetCacheCopyRecordPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows, err := parseCopyTable(string(raw), assetCacheCopyRecordPath, assetCacheCopyIDPattern, "an EAC-<n>")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rows
+}
+
+func TestAssetCacheCopyInventory(t *testing.T) {
+ rows := mustParseAssetCacheCopyRecord(t)
+ reportAll(t, checkMovesInventory(rows, assetCacheCopyIDs))
+ reportAll(t, checkMovesNoEmDash(rows))
+}
+
+func TestAssetCacheCopyBuiltRowsAreApprovedAndPresent(t *testing.T) {
+ rows := mustParseAssetCacheCopyRecord(t)
+ reportAll(t, checkRunBuilt(rows, assetCacheCopyBuilt))
+ built := builtRows(rows, assetCacheCopyBuilt)
+ for i := range built {
+ // A cell holds one line: \n\n is a paragraph break and \t a tab.
+ built[i].Text = strings.ReplaceAll(built[i].Text, `\n`, "\n")
+ built[i].Text = strings.ReplaceAll(built[i].Text, `\t`, "\t")
+ }
+ reportAll(t, checkRunApplied(built, "."))
+}
diff --git a/docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md b/docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md
new file mode 100644
index 000000000..fe96c576e
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md
@@ -0,0 +1,88 @@
+# Edge asset cache: copy record
+
+Every user-facing string plan 1 of the edge asset cache writes (spec
+`2026-10-24-edge-asset-cache-design.md` §3.1, §3.2 and §6): the flag help,
+the grammar refusals, the CLI's sentences, the console's refusals and the
+docs prose. The gate is `approved_asset_cache_copy_test.go` at the repo root.
+It reuses the moves gate's parser, so the format is the moves record's: one
+table per heading, columns id, file, text, status.
+
+The rules:
+
+- A row is `pending` until Paul approves it in the copy review (the
+ `copy-review` skill). His edits are applied to the text here verbatim and
+ the row becomes `approved`.
+- No pending string reaches code or docs. The task that writes a row's
+ text adds its id to `assetCacheCopyBuilt` in the gate in the same commit,
+ and from then on the gate fails unless the row is `approved` and its
+ text is in the file it names.
+- In a Go file the text is one string literal, or one `fmt.Sprintf`,
+ `fmt.Errorf` or `errors.New` format, equal to the row with a format verb
+ where the row has a `<placeholder>`. No trailing newline inside the
+ literal. A row that spans lines writes `\n` for the line break and `\t`
+ for a tab between table columns.
+- A row names one file, the one the gate searches. Where the same sentence
+ also appears in another file, the file cell says so in a note after the
+ path, and the other file carries the same text.
+- A table cell holds one line, so a row's text writes a paragraph break as
+ `\n\n`. In `docs/site/cli.md` each paragraph of a row is one line.
+- No em dashes. A full stop and a new sentence instead, continuing in lower
+ case. Docs prose keeps the CLI page's sentence case.
+- Ids are not renumbered. EAC-23 and EAC-24 are plan 3's docs rows and stay
+ free here.
+
+Placeholders: `<path>` an asset path as the person typed it; `<version>` a
+release version; `<host>` an instance's hostname; `<hosts>` a comma-separated
+list of hostnames; `<app>` an app name; `<n>`, `<done>` and `<total>` counts.
+
+The grammar the refusals enforce (spec §3.1): each path starts and ends with
+`/`, is at most 128 bytes, is clean apart from the trailing slash, and has
+none of `%`, `?`, `#`, `*` or `..`. `/` alone is refused. At most 8 paths,
+none a prefix of another.
+
+## Flags (plan 1, tasks 2 and 3)
+
+| id | file | text | status |
+|---|---|---|---|
+| EAC-1 | cli.go (also deploy.go, from the same constant) | a folder your build writes files to, for example /assets/. repeat the flag for up to 8 folders. servers near your visitors may keep copies of the files in them | pending |
+| EAC-2 | deploy.go | ship with no asset folders, ignoring any saved in .carlos/config | pending |
+| EAC-20 | deploy.go | --assets and --no-assets cannot be used together. pick one. | pending |
+
+## Grammar and refusals (plan 1, tasks 1 to 3)
+
+| id | file | text | status |
+|---|---|---|---|
+| EAC-3 | internal/release/assets.go | asset path <path> must start and end with /. name the folder your build writes to, for example /assets/. | pending |
+| EAC-4 | internal/release/assets.go | asset path / would cache every page on the site. name the folder your build writes to, for example /assets/. | pending |
+| EAC-5 | internal/release/assets.go | asset path <path> cannot contain %, ?, #, * or "..". write a plain folder path such as /assets/. | pending |
+| EAC-6 | internal/release/assets.go | asset path <path> must be at most 128 bytes, with no doubled slashes and no . parts. write it the way a clean folder path reads, for example /_app/immutable/. | pending |
+| EAC-7 | internal/release/assets.go | declare at most 8 asset paths, and none inside another, such as /a/ and /a/b/. drop the extras and try again. | pending |
+| EAC-8 | cli.go (also deploy.go and internal/console/apiship_assets.go) | a static site is already served from every region, so it has no asset folders to declare. leave --assets off. | pending |
+| EAC-9 | deploy.go | ignoring the asset folders saved in .carlos/config, because a static site is already served from every region. | pending |
+| EAC-21 | internal/console/apiship_assets.go | version <version> was already shipped with different asset folders. ship a new version to change them. | pending |
+| EAC-22 | apimode.go | this console is older than --assets and stored the release without your asset folders, so nothing would be kept near your visitors. nothing was promoted. update the console, then ship again under a new version. | pending |
+
+## CLI sentences (plan 1, task 10)
+
+| id | file | text | status |
+|---|---|---|---|
+| EAC-10 | instances_assets.go | <host> will keep copies of the asset folders its release declares. servers pick this up within a minute. | pending |
+| EAC-11 | instances_assets.go | <host> will keep no copies of its asset files, so every request for them reaches the app. servers pick this up within a minute. | pending |
+| EAC-12 | assets_cli.go | purged the saved asset files of <app> on <n> instances. servers drop the old copies within a minute. | pending |
+| EAC-13 | assets_cli.go | the purge of <app> changed <done> of <total> instances and left these unchanged: <hosts>. run carlos assets purge --app <app> again to finish it. | pending |
+| EAC-14 | assets_cli.go | HOST\tFOLDERS\tASSETS\tGENERATION\tPUBLISHED GENERATION\tPUBLISHED AT\n\na purge is done once the published generation matches the generation. servers that can reach the platform pick up a new generation within a minute. a server that cannot stops serving the app's saved asset files within an hour. | pending |
+
+## Console (plan 1, task 5)
+
+| id | file | text | status |
+|---|---|---|---|
+| EAC-15 | internal/console/apiassets.go | assets must be on or off. | pending |
+
+## Docs (plan 1, task 10)
+
+| id | file | text | status |
+|---|---|---|---|
+| EAC-16 | docs/site/cli.md | Throws away or inspects the copies that servers keep of an app's asset files. `carlos assets purge --app shop` makes every instance of the app forget what it has saved, so the next request for each file goes to the app. If it stops part way it names the instances that did not change, and running it again finishes the job. `carlos assets status --app shop` lists each instance with the folders its release declares, whether asset copying is on, and the generation the purge has reached. Copies are only kept for folders the release declares with `--assets`.\n\nA purge reaches every server that can read the platform's storage within a minute. A server that cannot stops serving the app's saved asset files within an hour. | pending |
+| EAC-17 | docs/site/cli.md | Turns asset copying on or off for one instance. `off` means requests for its asset files always reach the app, which helps while you are debugging. `on` goes back to following the folders its release declares. An instance follows its release until you set it. | pending |
+| EAC-18 | docs/site/cli.md | `--assets` names a folder your build writes its files to, for example `/assets/`. Repeat it for up to 8 folders. Each must start and end with `/`, be at most 128 bytes, and contain none of `%`, `?`, `#`, `*` or `..`. `/` alone is refused, because it would keep copies of every page. No folder may sit inside another. A static site cannot use it, because a static site is already served from every region. The folders belong to the release, so a rollback to an older build uses the folders that build declared. | pending |
+| EAC-19 | docs/site/cli.md | `carlos deploy` takes the same repeatable `--assets` flag and passes it to the ship. Save the list as `"assets": ["/assets/"]` in `.carlos/config` and a bare `carlos deploy` declares it on every release. A flag on the command line replaces the saved list for that run, and `--no-assets` ships with none. A release that declares no folders gets no copies, so deploying a build without them turns copying off for that build. `--assets` and `--no-assets` cannot be given together. | pending |