docs(04): create phase plan

This commit is contained in:
Jakub Zych
2026-09-18 13:24:54 +02:00
parent a5c8e0e31b
commit 846e6d5b74
6 changed files with 615 additions and 7 deletions

View File

@@ -149,7 +149,25 @@ Plans:
2. A translation key `vendor.plugin::group.key` resolves for pl and en, including a CLDR plural form, loaded from per-plugin per-locale YAML files with parameter substitution.
3. A plugin registers a mail template and layout by dotted name with the per-locale suffix convention, and it renders via `html/template` through a driver interface (SMTP via go-mail) in a test send.
**Plans**: TBD
**Plans**: 4 plans
Plans:
**Wave 1**
- [ ] 04-01-PLAN.md — Generate compiling plugin artifacts, registry and model import checks
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 04-02-PLAN.md — Load namespaced translations, plurals, parameters and locale fallbacks
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 04-03-PLAN.md — Register, render and send plugin mail through pluggable drivers
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 04-04-PLAN.md — Verify scaffolding, translation and mail with unit and SMTP integration tests
### Phase 5: Data layer full fidelity

View File

@@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
status: executing
stopped_at: Phase 4 context gathered
last_updated: "2026-09-18T00:14:26.029Z"
last_activity: 2026-09-17
last_updated: "2026-09-18T11:24:07.910Z"
last_activity: 2026-09-18 -- Phase 4 planning complete
progress:
total_phases: 15
completed_phases: 3
total_plans: 13
total_plans: 17
completed_plans: 13
percent: 20
---
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 4
Plan: Not started
Status: Ready to plan
Last activity: 2026-09-17
Status: Ready to execute
Last activity: 2026-09-18 -- Phase 4 planning complete
Progress: [██████████] 100%

View File

@@ -0,0 +1,154 @@
---
phase: 04-cli-scaffolding-i18n-and-mail
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- cmd/summer/main.go
- cmd/summer/main_test.go
- internal/build/scaffold.go
- internal/build/registry.go
- internal/build/leaf.go
- internal/build/build.go
- internal/build/build_test.go
- internal/build/stubs/plugin.tmpl
- internal/build/stubs/artifacts.tmpl
- internal/build/stubs/registry.tmpl
- pact/capabilities.go
autonomous: true
requirements: [CLI-02]
must_haves:
truths:
- "D-10: A generated plugin has root plugin.go and routes.go, Go leaf packages models/, classes/, controllers/, console/, jobs/, middleware/, updates/, and embedded lang/, views/mail/, config/ assets in the WinterCMS directory shape."
- "D-11: summer build rejects a models/ import of any sibling package in the same plugin, naming the plugin ID, source file, and offending import."
- "D-12: Each make command updates sorted registry.gen.go slices for migrations, commands, jobs, admin controllers, and models without rewriting hand-written plugin.go; an existing plugin receives a one-time accessor instruction."
- "D-13: make:model creates a GORM model with vendor_plugin_names table, timestamps, and a gormigrate create-table migration; --no-migration suppresses only that migration; make:migration appends a separate migration."
- "D-14: make:admin-controller produces a pact.AdminController with ID, model name, config directory, and Winter-shaped controllers/<name>/fields.yaml and columns.yaml."
- "D-15: make:job produces args with Kind() and a job with Work(context.Context, args) error behind pact.Job, without a River import."
- "D-16: All Go stubs render from embedded text/template files and pass go/format; a temporary hello plugin with all six artifact types passes go build and go vet."
- "D-17: make:<kind> accepts vendor.plugin and Name, or infers the plugin ID from plugin.go when invoked inside its directory."
artifacts:
- path: internal/build/registry.go
provides: deterministic per-plugin registry generation
- path: internal/build/leaf.go
provides: models sibling-import enforcement
- path: internal/build/stubs/plugin.tmpl
provides: named Winter-shaped plugin root, routes, leaf, and go.mod templates
- path: internal/build/stubs/artifacts.tmpl
provides: separately named model, migration, command, job, admin-controller, and YAML templates
- path: internal/build/stubs/registry.tmpl
provides: separately named generated-registry template
- path: cmd/summer/main.go
provides: six make commands
- path: pact/capabilities.go
provides: job, admin, language, and mail capability contracts
key_links:
- from: cmd/summer/main.go
to: internal/build/scaffold.go
via: make commands call the shared scaffold entry point
- from: internal/build/scaffold.go
to: internal/build/registry.go
via: successful artifact creation refreshes registry.gen.go
- from: internal/build/build.go
to: internal/build/leaf.go
via: pre-build check before go build subprocess
---
## Phase Goal
**As a** plugin developer, **I want to** generate compiling plugin artifacts, resolve translated strings, and send registered mail, **so that** I can port WinterCMS plugins into one SummerCMS binary.
<objective>
Generate every Phase 4 plugin artifact through the summer CLI and compile the resulting plugin.
Purpose: A porting agent can move each WinterCMS artifact into its corresponding Go package without hand-maintaining a plugin registry.
Output: six make commands, embedded stub templates, generated registry, and a build-time models leaf check.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md
@.planning/notes/plugin-layout-winter-directories.md
@.planning/phases/01-framework-kernel-foundation/01-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
<interfaces>
Existing: build.MakePlugin(ctx, startDir, id) (string, error); build.AddPlugin(ctx, startDir, pluginDir) error; build.App(ctx, appDir, out) error. pluginIDFromGo(path) reads a literal Plugin.ID return. pact.HasMigrations.Migrations() returns []*gormigrate.Migration; pact.HasCommands.Commands() returns []bonfire.Command; pact.HasModels.Models() returns []any. A scaffolded plugin implements party.Plugin with ID, Requires, Register, Boot. The app manifest lists plugin ID and module path. Keep the current plugin:add and go.work behavior.
New contract to define before generated consumers: pact.JobArgs has Kind() string; pact.Job has Work(context.Context, pact.JobArgs) error; pact.HasJobs returns []pact.Job; pact.AdminController has ID() string, ModelName() string, ConfigDir() string; pact.HasAdminControllers returns []pact.AdminController. Add pact.HasLang.LangFS() fs.FS and pact.HasMailTemplates with MailTemplatesFS() fs.FS, MailTemplates() []string, MailLayouts() map[string]string for Plans 02 and 03. Generated root plugin.go calls generatedModels, generatedMigrations, generatedCommands, generatedJobs, and generatedAdminControllers from registry.gen.go; existing plugins opt in by appending those same accessors.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Prove and create the compiling Winter-shaped plugin path</name>
<files>internal/build/build_test.go, internal/build/scaffold.go, internal/build/registry.go, internal/build/stubs/plugin.tmpl, internal/build/stubs/registry.tmpl, pact/capabilities.go</files>
<read_first>internal/build/build_test.go; internal/build/scaffold.go; internal/build/build.go; internal/build/manifest.go; pact/capabilities.go; examples/hello/plugins/base/plugin.go; examples/hello/go.mod; .planning/notes/plugin-layout-winter-directories.md; 04-CONTEXT.md D-10, D-12, D-16</read_first>
<action>Start with a failing end-to-end TestScaffoldPluginSmoke in internal/build/build_test.go that copies examples/hello, creates and adds a plugin, then builds it. Per D-16, migrate make:plugin's source and go.mod string builders to embedded text/template under internal/build/stubs: plugin.tmpl defines separately executable named templates for plugin.go, routes.go, each leaf doc.go, and go.mod; registry.tmpl defines the registry.go template. Parse embedded files once and execute each named definition, formatting rendered Go before writes. Per D-10, create root routes.go and a doc.go in each leaf package, plus nonhidden embed-compatible lang and mail assets. Per D-12, generate deterministic registry.gen.go with empty accessors. Define the pact contracts in the interfaces block so generated plugin.go compiles and its capability methods can return registry slices. Preserve party.Register, the current go.mod/toolchain/replacement flow, and hand-written plugin.go ownership. Record the initial red test result, then make it green before committing so every commit keeps root go vet and go test ./... green.</action>
<verify><automated>go test ./internal/build -run TestScaffoldPluginSmoke -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>A new plugin has the Winter directory shape, working embedded assets and registry.gen.go; build succeeds before any individual artifact is added.</acceptance_criteria>
<done>TestScaffoldPluginSmoke passes for plugin creation and existing scaffold behavior remains green.</done>
</task>
<task type="auto">
<name>Task 2: Generate model, migration, and command slices</name>
<files>internal/build/scaffold.go, internal/build/registry.go, internal/build/stubs/artifacts.tmpl, cmd/summer/main.go, cmd/summer/main_test.go, internal/build/build_test.go</files>
<read_first>internal/build/scaffold.go; internal/build/registry.go; internal/build/stubs/plugin.tmpl; internal/build/stubs/registry.tmpl; cmd/summer/main.go; cmd/summer/main_test.go; internal/build/build_test.go; pact/capabilities.go; lagoon/migrations.go; examples/hello/plugins/greeter/plugin.go; 04-CONTEXT.md D-12, D-13, D-17</read_first>
<action>Add make:model, make:migration, and make:command to bonfire with the D-17 argument forms and pluginIDFromGo inference. Validate identifier, path containment, duplicate artifact, and module ownership before writes. In artifacts.tmpl, define separately executable named templates for model.go, migration.go, and command.go. Per D-13, render model table vendor_plugin_names with timestamps; emit an explicit gormigrate Up/Down create-table migration by default, with --no-migration omitting only that file. A standalone make:migration creates an ordered gormigrate entry; make:command creates a console/ bonfire.Command with plugin-prefixed colon name. Regenerate sorted slices and imports atomically, preserving handwritten plugin.go per D-12. Add TestScaffoldCoreArtifacts to build/vet these artifacts and check repeated generation leaves registry bytes unchanged.</action>
<verify><automated>go test ./internal/build ./cmd/summer -run 'TestScaffoldCoreArtifacts|TestToolCommandNames' -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>The three commands create compiling artifacts and expose them through the generated registry; --no-migration omits only the model migration.</acceptance_criteria>
<done>A temporary generated plugin builds and vets after model, migration, and command creation.</done>
</task>
<task type="auto">
<name>Task 3: Generate jobs and admin controllers, then enforce model imports</name>
<files>internal/build/scaffold.go, internal/build/registry.go, internal/build/leaf.go, internal/build/build.go, internal/build/stubs/artifacts.tmpl, cmd/summer/main.go, cmd/summer/main_test.go, internal/build/build_test.go</files>
<read_first>internal/build/scaffold.go; internal/build/registry.go; internal/build/stubs/artifacts.tmpl; internal/build/build.go; internal/build/manifest.go; cmd/summer/main.go; cmd/summer/main_test.go; internal/build/build_test.go; pact/capabilities.go; .planning/notes/plugin-layout-winter-directories.md; 04-CONTEXT.md D-11, D-14, D-15</read_first>
<action>Add make:job and make:admin-controller with the same validated argument resolution. In artifacts.tmpl, add separately executable named definitions for job.go, admin_controller.go, fields.yaml, and columns.yaml. Per D-15, job args implement pact.JobArgs.Kind and the job implements pact.Job.Work, returning an error for an unexpected args type; no River import. Per D-14, admin controller implements the pact interface and has controllers/<name>/fields.yaml and columns.yaml in the Winter shape. Extend registry slices for both. Per D-11, in build.App inspect Go imports in each manifest plugin's models/ package before invoking go build; reject an import of its own classes/, controllers/, console/, jobs/, middleware/, or updates/ package with plugin ID, source file and import path. Keep the check limited to this rule, including manually written models. Complete the smoke case for all six artifact types and explicit leaf rejection.</action>
<verify><automated>go test ./internal/build ./cmd/summer -run 'TestScaffoldAllArtifacts|TestModelsLeaf|TestToolCommandNames' -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>All six make commands generate buildable, vet-clean output; a sibling import in models fails before go build with the required diagnostic.</acceptance_criteria>
<done>The full scaffold smoke case passes, registry output is deterministic, and handwritten plugin.go remains byte-identical.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| CLI input to filesystem | Plugin IDs and artifact names choose generated paths and source identifiers. |
| Plugin source to build subprocess | User-written imports enter the compiled plugin dependency graph. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-01 | Tampering | make:* path/source generation | mitigate | Validate Go identifiers, module path and underRoot containment before writing; reject duplicates; format before atomic registry rename. |
| T-04-02 | Tampering | models/ imports | mitigate | Parse imports before build and fail with plugin ID, file, and sibling import; test an injected violation. |
| T-04-03 | Denial of service | repeated make:* | mitigate | Refuse duplicate artifact names and keep registry generation deterministic/idempotent. |
| T-04-SC | Tampering | Go module resolution | mitigate | Use only research-named existing dependencies for this plan; no npm, pip, or cargo install. |
</threat_model>
<verification>
After every task, run its focused smoke plus root go vet ./... and go test ./.... In a temporary copy of examples/hello, run go build and go vet after generating every artifact, including --no-migration and repeated make commands. Inspect a deliberately forbidden models/ sibling import diagnostic.
</verification>
<success_criteria>
CLI-02 is observable through six commands; each generated artifact compiles and vets; registry.gen.go is stable and auto-wired; a models/ sibling import fails with an actionable message. No file outside summercms.go is edited.
</success_criteria>
<output>
Create .planning/phases/04-cli-scaffolding-i18n-and-mail/04-01-SUMMARY.md when done.
</output>

View File

@@ -0,0 +1,139 @@
---
phase: 04-cli-scaffolding-i18n-and-mail
plan: "02"
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- phrasebook/loader.go
- phrasebook/translator.go
- phrasebook/translator_test.go
- party/registry.go
- examples/hello/plugins/base/plugin.go
- examples/hello/plugins/base/lang/en/lang.yaml
- examples/hello/plugins/base/lang/pl/lang.yaml
- examples/hello/hello_test.go
- go.mod
- go.sum
autonomous: true
requirements: [I18N-01]
must_haves:
truths:
- "D-01: A plugin's HasLang FS loads lang/<locale>/<group>.yaml, flattens nested keys, and resolves vendor.plugin::group.dot.path."
- "D-02: YAML CLDR category maps and Laravel pipe strings both choose correct pl/en plural forms, including {0} and [2,*] explicit conditions."
- "D-03: :name, :Name, and :NAME are substituted after plural selection without changing PHP-style source values."
- "D-04: pl-PL lookup checks pl-PL, then pl, then configured fallback, then returns the raw key; a missing key logs once per key outside production."
- "D-05: app.locale and app.fallback_locale select default/fallback locale, each defaulting to en in the framework; no Polish framework default is installed."
artifacts:
- path: phrasebook/loader.go
provides: embedded YAML catalog loading and namespace validation
- path: phrasebook/translator.go
provides: app-scoped Get and Choice lookup, CLDR and pipe selection, fallback and parameters
- path: party/registry.go
provides: HasLang activation and translator publication before plugin Boot
key_links:
- from: examples/hello/plugins/base/plugin.go
to: pact.HasLang
via: LangFS embedded assets
- from: party/registry.go
to: phrasebook/loader.go
via: ordered plugin capability scan before Boot
- from: phrasebook/translator.go
to: towel/context.go
via: towel.Locale accessor for the request-context seam
---
## Phase Goal
**As a** plugin developer, **I want to** generate compiling plugin artifacts, resolve translated strings, and send registered mail, **so that** I can port WinterCMS plugins into one SummerCMS binary.
<objective>
Resolve plugin-owned Polish and English translation keys, plural forms, parameters, and locale fallbacks.
Purpose: A ported plugin can retain WinterCMS key names and message values while SummerCMS chooses the requested language.
Output: phrasebook service, HasLang boot registration, and a working hello plugin catalog.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-01-SUMMARY.md
@../modules/summer-phrasebook/README.md
<interfaces>
From Plan 01: pact.HasLang.LangFS() fs.FS. party.Activate(app, ids) resolves plugins in Requires order, merges HasConfig, then runs all Register methods before Boot. backpack.App.Publish[T] and Lookup[T] are app-scoped. compass.Config.String reads app.locale and app.fallback_locale; towel.Locale(ctx) reads the existing Accept-Language context value from surf. Define phrasebook.Translator with Get(ctx, key, params) string and Choice(ctx, key, count, params) string, plus explicit-locale variants for callers without a request context. Use go-i18n/v2 v2.6.1 only to choose CLDR category labels; phrasebook owns source string and placeholder replacement.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Load a namespaced translation from an embedded plugin</name>
<files>phrasebook/loader.go, phrasebook/translator.go, phrasebook/translator_test.go, party/registry.go, examples/hello/plugins/base/plugin.go, examples/hello/plugins/base/lang/en/lang.yaml, examples/hello/plugins/base/lang/pl/lang.yaml, examples/hello/hello_test.go</files>
<read_first>phrasebook/loader.go (create); phrasebook/translator.go (create); phrasebook/translator_test.go (create); party/registry.go; pact/capabilities.go; backpack/app.go; compass/config.go; examples/hello/plugins/base/plugin.go; examples/hello/hello_test.go; examples/hello/config/app.yaml; 04-CONTEXT.md D-01, D-05; 04-RESEARCH.md Translations</read_first>
<action>Write an end-to-end hello smoke case first: Activate the base plugin, look up a Polish and English vendor.plugin::group.key, and observe the initial failure. In the same task, implement fs.WalkDir catalog loading from HasLang.LangFS, sorted file processing, nested YAML flattening and full namespace construction. Reject malformed paths, non-string leaves, duplicate keys and duplicate namespace owners with plugin/file/key context. Add real en/pl hello YAML and embed it in base.Plugin. Construct and publish one phrasebook.Translator on the app before Boot, using app.locale and app.fallback_locale with framework defaults en/en. Make the smoke green before committing; keep root vet/test green at the commit.</action>
<verify><automated>go test ./phrasebook -run TestTranslationSmoke -short -count=1 &amp;&amp; go -C examples/hello test ./... &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>A hello plugin can resolve its own dotted key in en and pl after party.Activate; malformed and colliding catalogs fail activation with context.</acceptance_criteria>
<done>A caller retrieves a string through the published translator from embedded YAML.</done>
</task>
<task type="auto">
<name>Task 2: Select CLDR and Laravel plural forms with PHP parameters</name>
<files>phrasebook/loader.go, phrasebook/translator.go, phrasebook/translator_test.go, examples/hello/plugins/base/lang/en/lang.yaml, examples/hello/plugins/base/lang/pl/lang.yaml, go.mod, go.sum</files>
<read_first>phrasebook/loader.go; phrasebook/translator.go; phrasebook/translator_test.go; examples/hello/plugins/base/lang/en/lang.yaml; examples/hello/plugins/base/lang/pl/lang.yaml; go.mod; go.sum; 04-CONTEXT.md D-02, D-03; 04-RESEARCH.md Library findings; ../modules/summer-phrasebook/README.md</read_first>
<action>Add go-i18n/v2 v2.6.1 as the research-named dependency. Keep plural selection and lookup together in translator.go to keep the package focused. Interpret a YAML map as a plural map only when all keys are CLDR category names, require other, and validate categories for the locale; otherwise recurse as a nested key namespace. Use go-i18n solely to select a category label from count, then choose the unmodified YAML message and replace :name, :Name, :NAME within phrasebook. Parse pipe strings in CLDR category order, with exact {n} and inclusive [a,b] or [a,*] conditions taking precedence; reject malformed or incomplete pipes at catalog load. Cover pl counts 1, 2, 5, 22 and en 1, 2 plus zero and fractional values in the focused smoke. Do not let go-i18n evaluate message text as a Go template.</action>
<verify><automated>go test ./phrasebook -run 'TestTranslationSmoke|TestPluralSmoke' -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>Both plural syntaxes select the specified forms; all three parameter case variants work on the selected text; bad plural definitions fail at load time.</acceptance_criteria>
<done>Polish and English plural examples work through the published translator.</done>
</task>
<task type="auto">
<name>Task 3: Apply configured locale fallback and missing-key behavior</name>
<files>phrasebook/translator.go, phrasebook/translator_test.go, examples/hello/hello_test.go</files>
<read_first>phrasebook/translator.go; phrasebook/translator_test.go; examples/hello/hello_test.go; towel/context.go; surf/router.go; compass/config.go; 04-CONTEXT.md D-04, D-05</read_first>
<action>Use towel.Locale(ctx) when present and app.locale otherwise; expose explicit-locale lookup for non-request callers. Resolve requested tag first, then parent tag, then app.fallback_locale, then raw key. Deduplicate locale steps without changing their priority. In non-production mode log a missing key once per key per app-owned translator; production lookup is silent. Add hello smoke cases for pl-PL to pl, fallback en and raw key. Keep this as a context seam only: do not add Phase 7 preferred-locale lookup or per-user state.</action>
<verify><automated>go test ./phrasebook -run 'TestTranslationSmoke|TestLocaleFallbackSmoke' -short -count=1 &amp;&amp; go -C examples/hello test ./... &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>Requested/parent/fallback/raw order is observable, en/en defaults hold without app config, and missing-key logging occurs once outside production.</acceptance_criteria>
<done>A plugin resolves a full Polish or English key and a CLDR plural through the app-scoped service.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Embedded plugin YAML to app service | Plugin-owned catalog bytes become lookup data for all callers. |
| Request context to lookup | An untrusted locale tag chooses a catalog and fallback sequence. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-04 | Tampering | phrasebook loader | mitigate | Reject malformed paths, YAML leaves, duplicate namespaces, duplicate keys and invalid plural definitions at boot with plugin/file/key context. |
| T-04-05 | Denial of service | locale and pipe parsing | mitigate | Bound parser work to loaded catalog size; validate conditions once at boot and use a finite fallback chain per lookup. |
| T-04-06 | Information disclosure | missing-key logging | mitigate | Log only the key once per translator in non-production; never log parameters or request data. |
| T-04-SC | Tampering | Go module resolution | mitigate | Pin research-named go-i18n/v2 v2.6.1; npm/pip/cargo package gate is inapplicable. |
</threat_model>
<verification>
Run focused translation smoke and root go vet ./... plus go test ./... after every task. Run the hello activation smoke from the examples/hello module as a separate command before plan completion. Confirm no package-level locale state or second context key appears.
</verification>
<success_criteria>
I18N-01 resolves vendor.plugin::group.key from embedded en/pl YAML, handles nested keys, both plural syntaxes, case-sensitive parameter variants, configured locale fallback and raw-key behavior. A malformed catalog fails plugin activation by name.
</success_criteria>
<output>
Create .planning/phases/04-cli-scaffolding-i18n-and-mail/04-02-SUMMARY.md when done.
</output>

View File

@@ -0,0 +1,154 @@
---
phase: 04-cli-scaffolding-i18n-and-mail
plan: "03"
type: execute
wave: 3
depends_on: ["04-01", "04-02"]
files_modified:
- postcard/mailer.go
- postcard/templates.go
- postcard/drivers.go
- postcard/assets/default.htm
- postcard/mailer_test.go
- party/registry.go
- examples/hello/plugins/base/plugin.go
- examples/hello/plugins/base/views/mail/hello.htm
- examples/hello/plugins/base/views/mail/hello-en.htm
- examples/hello/plugins/base/views/mail/layouts/hello.htm
- examples/hello/config/mail.yaml
- examples/hello/hello_test.go
- go.mod
- go.sum
autonomous: true
requirements: [I18N-03]
must_haves:
truths:
- "D-06: A plugin registers vendor.plugin::mail.name from views/mail/<name>.htm; INI-style subject/description/layout headers precede == and a templated Markdown body."
- "D-07: html/template substitutes body variables into Markdown, Goldmark renders HTML, and the substituted Markdown becomes the text part; final HTML rejects raw HTML and unsafe links."
- "D-08: An unsuffixed template is the app-default-locale file and explicit -en siblings are selected by the caller's full dotted name; Send performs no locale selection."
- "D-09: A plugin maps short layout names to vendor.plugin::mail.layouts.name; each layout has header, text wrapper and HTML wrapper sections using .Content, shared css/brandCss values, and postcard ships a neutral default layout."
- "D-18: smtp via go-mail, log, and memory drivers share one interface; mail.* config and SUMMER_MAIL__ overrides select driver/connection settings."
- "D-19: A registered template/layout renders via memory with asserted subject, HTML and text; Mailpit later proves real SMTP receipt, failing when Docker is absent except under -short."
- "D-20: backpack publishes postcard.Mailer with Send(ctx, postcard.Message{Template, To, Cc, Bcc, ReplyTo, Vars}) error and an optional subject override; no per-template Go type is required."
- "D-21: Send propagates driver errors without retry; missing template files or unregistered referenced layouts fail Boot with plugin ID and missing name."
artifacts:
- path: postcard/templates.go
provides: Winter-shaped template and layout registration/rendering
- path: postcard/mailer.go
provides: app-scoped Send service and message contracts
- path: postcard/drivers.go
provides: memory, log, and context-aware go-mail SMTP drivers behind one interface
- path: postcard/assets/default.htm
provides: neutral framework layout
- path: party/registry.go
provides: HasMailTemplates registration and named boot validation
key_links:
- from: examples/hello/plugins/base/plugin.go
to: pact.HasMailTemplates
via: embedded mail FS plus explicit template/layout names
- from: party/registry.go
to: postcard/mailer.go
via: ordered registration and app.Publish[postcard.Mailer] before Boot
- from: postcard/mailer.go
to: postcard/drivers.go
via: driver.Send after render and validation
---
## Phase Goal
**As a** plugin developer, **I want to** generate compiling plugin artifacts, resolve translated strings, and send registered mail, **so that** I can port WinterCMS plugins into one SummerCMS binary.
<objective>
Register Winter-shaped mail templates and layouts, render their text and HTML parts, and send via memory, log, or SMTP.
Purpose: Plugin mail can be ported by copying the familiar file shape and selecting a driver through application config.
Output: postcard service, template/layout parser, three drivers, and hello mail assets.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-01-SUMMARY.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-02-SUMMARY.md
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm
<interfaces>
From Plan 01: pact.HasMailTemplates has MailTemplatesFS() fs.FS, MailTemplates() []string, and MailLayouts() map[string]string, with short layout name as map key and full dotted layout name as value. party.Activate can inspect this capability in Requires order after HasConfig merge. backpack.App.Publish[T] and Lookup[T] keep the mailer app-scoped. Define postcard.Message with Template string, To/Cc/Bcc []string, ReplyTo string, Vars map[string]any, and optional Subject string override. Define postcard.Mailer as Send(context.Context, Message) error and a driver contract Send(context.Context, RenderedMessage) error. A RenderedMessage contains validated recipients, subject, HTML and text bodies. The neutral default layout is internal to postcard, while plugin aliases resolve only names registered by that plugin. Read mail.driver, mail.from, mail.smtp.host, mail.smtp.port, mail.smtp.username, mail.smtp.password, mail.smtp.tls, and mail.smtp.timeout from compass. For the log driver, look up an app-scoped *slog.Logger from backpack and use slog.Default only when no app logger was published.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Render and send one registered template through memory</name>
<files>postcard/mailer.go, postcard/templates.go, postcard/drivers.go, postcard/assets/default.htm, postcard/mailer_test.go, examples/hello/plugins/base/views/mail/hello.htm, examples/hello/plugins/base/views/mail/hello-en.htm, go.mod, go.sum</files>
<read_first>postcard/mailer.go (create); postcard/templates.go (create); postcard/drivers.go (create); pact/capabilities.go; backpack/services.go; compass/config.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm; 04-CONTEXT.md D-06 through D-08 and D-20; 04-RESEARCH.md Mail</read_first>
<action>Write a failing memory-driver smoke that registers a template, calls Send and checks subject, HTML and text; then implement that path before committing. Parse exact Winter INI header followed by a separator line == and Markdown body, including templated subject. Register only declared dotted names and map each to views/mail/<name>.htm under the owner plugin FS. Render html/template substitutions on Markdown, keep that result as text, then use research-named Goldmark for HTML without unsafe HTML enabled. Validate the final HTML against dangerous schemes and raw HTML; keep any trusted HTML type internal to layout rendering. Add an embedded neutral default layout. Make hello.htm and hello-en.htm distinct, with the caller choosing the full -en name; do no locale lookup in Send. In drivers.go, define the driver contract and store rendered messages in a concurrency-safe memory implementation; Task 3 adds log and SMTP implementations to this same file.</action>
<verify><automated>go test ./postcard -run TestMailRenderSmoke -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>Sending through memory yields the chosen template's subject, Markdown text and safe HTML; the -en sibling is used only when named explicitly.</acceptance_criteria>
<done>A direct postcard.Mailer send with a registered template succeeds through memory.</done>
</task>
<task type="auto">
<name>Task 2: Register plugin layouts and publish the mailer at Boot</name>
<files>postcard/templates.go, postcard/mailer.go, postcard/mailer_test.go, party/registry.go, examples/hello/plugins/base/plugin.go, examples/hello/plugins/base/views/mail/layouts/hello.htm, examples/hello/hello_test.go</files>
<read_first>postcard/templates.go; postcard/mailer.go; postcard/mailer_test.go; party/registry.go; pact/capabilities.go; examples/hello/plugins/base/plugin.go; examples/hello/hello_test.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm; /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php; 04-CONTEXT.md D-09, D-20, D-21</read_first>
<action>Parse layout files as header, text wrapper, and HTML wrapper split by two == lines; render .Content into each and resolve configured or plugin-provided css and brandCss as shared values. Require a short-name mapping such as hello to golem15.hello::mail.layouts.hello and resolve a template's layout header through that map; default selects postcard's neutral layout. In party.Activate, collect HasMailTemplates catalogs in plugin order and publish postcard.Mailer to backpack before plugin Boot; validate each catalog at its Boot transition and wrap missing-file or unknown-layout errors as party: boot <plugin ID> with the full missing name. Add hello plugin FS, explicit template/layout registrations, and an activation smoke that obtains postcard.Mailer from backpack. Keep the framework generic: no Fonoteka-specific layout is copied into it.</action>
<verify><automated>go test ./postcard -run 'TestMailRenderSmoke|TestMailLayoutSmoke' -short -count=1 &amp;&amp; go -C examples/hello test ./... &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>A hello plugin registers its names and sends through the published mailer; text and HTML wrappers both contain content; broken registrations fail Boot with names.</acceptance_criteria>
<done>The app-scoped mailer is available to plugin Boot and its layout registration is validated.</done>
</task>
<task type="auto">
<name>Task 3: Add config-selected log and SMTP delivery</name>
<files>postcard/drivers.go, postcard/mailer.go, postcard/mailer_test.go, examples/hello/config/mail.yaml, go.mod, go.sum</files>
<read_first>postcard/drivers.go; postcard/mailer.go; postcard/mailer_test.go; lagoon/connection.go; compass/config.go; compass/env.go; examples/hello/config/app.yaml; 04-CONTEXT.md D-18 through D-21; 04-RESEARCH.md Mail</read_first>
<action>Load mail.driver, mail.from, and mail.smtp.host/port/username/password/tls/timeout via compass, including SUMMER_MAIL__ overrides, and select exactly memory, log or smtp. In drivers.go, keep the log and SMTP implementations separate by type and constructor under the shared Driver interface. The log driver writes rendered headers and the text part to a *slog.Logger from backpack for development, using slog.Default only if the app has not published one; exclude credentials. Implement SMTP with research-named go-mail, context-aware sending, structured To/Cc/Bcc/ReplyTo setters, explicit TLS policy including an opt-in NoTLS setting for Mailpit, and no silent production downgrade. Validate addresses and reject CR/LF in subject/header values before constructing the go-mail message. Return any driver failure from Send unchanged except safe package context, without retries or secret/body leakage. Use a deterministic in-process failing driver smoke here; the real Mailpit receipt is Plan 04's dedicated integration gate.</action>
<verify><automated>go test ./postcard -run 'TestMailRenderSmoke|TestMailDriverSmoke' -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>mail.driver selects memory/log/smtp from config, the SMTP branch constructs a valid message and propagates errors, and unsafe headers or addresses are rejected before send.</acceptance_criteria>
<done>Registered template sends through all three driver contracts and a failing driver surfaces its error to the caller.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Plugin files and caller Vars to rendered mail | Untrusted values enter Markdown, HTML, layouts and headers. |
| mail.* config to network | SMTP host, TLS mode and credentials control outbound transport. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-07 | Tampering | template/layout registration | mitigate | Validate dotted owner names and paths; fail Boot for missing declared files and unknown layout aliases. |
| T-04-08 | Information disclosure | Markdown/HTML renderer | mitigate | Keep Goldmark unsafe HTML disabled, verify final HTML/URL schemes, and permit trusted layout HTML only from internal generated content. |
| T-04-09 | Tampering | mail headers | mitigate | Reject CR/LF in subject and header values and validate all recipient addresses before go-mail setters. |
| T-04-10 | Information disclosure | SMTP credentials/logging | mitigate | Require explicit TLS policy; redact secrets and bodies from SMTP errors; log driver never logs credentials. |
| T-04-11 | Denial of service | SMTP send | mitigate | Use context-aware send, configured timeout, and return driver errors without retry loops. |
| T-04-SC | Tampering | Go module resolution | mitigate | Add only research-named Goldmark and go-mail modules; npm/pip/cargo package gate is inapplicable. |
</threat_model>
<verification>
After each task run focused postcard smoke and root go vet ./... plus go test ./.... Run hello activation smoke from its nested module at the end. Confirm final rendered HTML, text and subject separately. Plan 04 owns adversarial and Mailpit integration coverage.
</verification>
<success_criteria>
I18N-03 has explicit dotted template and short layout registration, Winter-shaped parsing, safe HTML and Markdown text, caller-selected locale suffix, app-scoped Send, and memory/log/SMTP drivers selected by mail.*. Missing registrations and SMTP errors are visible to callers.
</success_criteria>
<output>
Create .planning/phases/04-cli-scaffolding-i18n-and-mail/04-03-SUMMARY.md when done.
</output>

View File

@@ -0,0 +1,143 @@
---
phase: 04-cli-scaffolding-i18n-and-mail
plan: "04"
type: execute
wave: 4
depends_on: ["04-01", "04-02", "04-03"]
files_modified:
- internal/build/build_test.go
- cmd/summer/main_test.go
- phrasebook/translator_test.go
- postcard/mailer_test.go
- postcard/templates_test.go
- postcard/smtp_test.go
- postcard/mailpit_test.go
- party/registry_test.go
- examples/hello/hello_test.go
- scripts/check-phase4.sh
autonomous: true
requirements: [CLI-02, I18N-01, I18N-03]
must_haves:
truths:
- "CLI-02 and D-10 through D-17: Every make command produces a compiling, vet-clean artifact in a copied hello app; registry bytes are stable, --no-migration works, hand-written plugin.go is untouched, and models sibling imports fail by name."
- "I18N-01 and D-01 through D-05: Embedded en/pl catalogs resolve nested keys, both plural syntaxes, parameter variants, fallback order, raw missing keys, and named boot errors for malformed assets."
- "I18N-03 and D-06 through D-09/D-18 through D-21: Registered mail and layout render safe subject, HTML and text through memory; invalid registrations, dangerous content and headers fail; SMTP delivery is visible in Mailpit."
- "D-19: go test ./postcard -run TestSMTPMailpit -count=1 fails when Docker is unavailable, while -short skips only the Mailpit container test."
artifacts:
- path: internal/build/build_test.go
provides: six-artifact compile/vet and leaf-import regression tests
- path: phrasebook/translator_test.go
provides: catalog, CLDR, pipe, substitution, and fallback boundary tests
- path: postcard/mailpit_test.go
provides: real SMTP receipt assertion via Mailpit HTTP API
- path: scripts/check-phase4.sh
provides: repeatable root, hello and Mailpit sign-off gate
key_links:
- from: scripts/check-phase4.sh
to: postcard/mailpit_test.go
via: full root go test ./... includes the non-short Mailpit test
- from: internal/build/build_test.go
to: internal/build/scaffold.go
via: copied hello app drives public make/build API
- from: examples/hello/hello_test.go
to: party/registry.go
via: activation checks published phrasebook and postcard services
---
## Phase Goal
**As a** plugin developer, **I want to** generate compiling plugin artifacts, resolve translated strings, and send registered mail, **so that** I can port WinterCMS plugins into one SummerCMS binary.
<objective>
Prove the three Phase 4 slices with focused unit coverage and a real SMTP integration receipt.
Purpose: Scaffolding, translation and mail contracts remain checkable as later plugin ports use them.
Output: adversarial tests, Mailpit testcontainer coverage, and one repeatable phase gate.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-RESEARCH.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-VALIDATION.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-01-SUMMARY.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-02-SUMMARY.md
@.planning/phases/04-cli-scaffolding-i18n-and-mail/04-03-SUMMARY.md
<interfaces>
The public paths under test are build.MakePlugin, build.AddPlugin, build.App and the five new make commands; phrasebook.Translator.Get/Choice plus explicit-locale variants; postcard.Mailer.Send(ctx, postcard.Message) and its three drivers; party.Activate with optional HasLang and HasMailTemplates. Existing internal/build/build_test.go copies examples/hello and rewrites the local framework replacement. Existing lagoon/postgres_test.go shows the testcontainers rule: Docker failure fails full tests and testing.Short skips container startup. Mailpit exposes SMTP 1025 and HTTP API 8025; use axllent/mailpit:v1.31.1, a released upstream tag, with a bounded API poll after SMTP send.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Close scaffold and CLI command boundary coverage</name>
<files>internal/build/build_test.go, cmd/summer/main_test.go</files>
<read_first>internal/build/build_test.go; cmd/summer/main_test.go; internal/build/scaffold.go; internal/build/registry.go; internal/build/leaf.go; internal/build/build.go; .planning/notes/plugin-layout-winter-directories.md; 04-CONTEXT.md D-10 through D-17; 04-VALIDATION.md</read_first>
<action>Extend the temporary hello-app test through all six make commands and run both go build and go vet in that copied workspace. Assert exact generated paths, model table/timestamps, migration Up/Down registration, --no-migration, job Kind/Work contract, admin YAML paths, each registry slice and stable bytes after repeated commands. Inject a models/ to classes/ import and assert summer build reports plugin ID, source and import; cover malformed identifiers, traversal, duplicate names and the one-time accessor instruction for a handwritten plugin without rewriting its plugin.go. Add the five command names and argument forms to cmd/summer/main_test.go. Use public CLI/build paths, not only private helpers.</action>
<verify><automated>go test ./internal/build ./cmd/summer -run 'TestScaffoldAllArtifacts|TestModelsLeaf|TestToolCommandNames' -short -count=1 &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>Every generated artifact compiles and vets in the copied app; intentional malformed input and sibling import fail with named errors.</acceptance_criteria>
<done>CLI-02 has green positive and negative tests through the public command/build flow.</done>
</task>
<task type="auto">
<name>Task 2: Close translation lookup and catalog boundary coverage</name>
<files>phrasebook/translator_test.go, party/registry_test.go, examples/hello/hello_test.go</files>
<read_first>phrasebook/loader.go; phrasebook/translator.go; phrasebook/translator_test.go; party/registry.go; party/registry_test.go; examples/hello/hello_test.go; 04-CONTEXT.md D-01 through D-05; 04-VALIDATION.md</read_first>
<action>Keep the related loader, plural and lookup cases together in translator_test.go. Use fstest.MapFS fixtures to prove nested YAML flattening, namespace ownership, malformed paths and leaves, and boot errors naming plugin/file/key. Assert map plurals for pl counts 0/1/2/5/22 and fractional values, en 1/2, pipe ordered forms plus {0} and [2,*], and invalid categories/brackets/forms. Assert :name/:Name/:NAME, requested pl-PL to pl to configured en to raw key order, app.locale/fallback_locale defaults en/en, and once-per-key non-production logging without parameter leakage. The hello activation test must obtain the translator from backpack and use towel.WithLocale to prove the existing context seam.</action>
<verify><automated>go test ./phrasebook ./party -short -count=1 &amp;&amp; go -C examples/hello test ./... &amp;&amp; go vet ./... &amp;&amp; go test ./...</automated></verify>
<acceptance_criteria>All I18N-01 decisions have tests that fail if lookup order, plural category, substitution or catalog validation regresses.</acceptance_criteria>
<done>The published translator passes Polish/English happy paths and malformed-catalog failures.</done>
</task>
<task type="auto">
<name>Task 3: Close mail safety, driver, and Mailpit receipt coverage</name>
<files>postcard/mailer_test.go, postcard/templates_test.go, postcard/smtp_test.go, postcard/mailpit_test.go, examples/hello/hello_test.go, scripts/check-phase4.sh</files>
<read_first>postcard/mailer.go; postcard/templates.go; postcard/drivers.go; postcard/mailer_test.go; examples/hello/hello_test.go; lagoon/postgres_test.go; 04-CONTEXT.md D-06 through D-09 and D-18 through D-21; 04-VALIDATION.md; https://mailpit.axllent.org/docs/api-v1/</read_first>
<action>Assert memory delivery preserves To/Cc/Bcc/ReplyTo, templated subject, Markdown text, final HTML, layout wrappers, neutral default and explicit -en selection. Add adversarial Vars with raw tags, Markdown links and javascript: URLs; test unsafe rendered HTML is absent, subject CR/LF and invalid recipients are rejected, missing template/layout fails Boot with plugin ID and name, and a failing driver error reaches Send without retry. Test log driver redacts credentials and SMTP TLS/no-downgrade policy. Add TestSMTPMailpit using testcontainers-go image axllent/mailpit:v1.31.1, mapped SMTP 1025 and HTTP 8025, go-mail send, and bounded HTTP API polling that asserts recipient, subject, HTML and text receipt; skip only under testing.Short and fail when Docker is absent otherwise. Add scripts/check-phase4.sh that runs root go vet ./..., root go test ./... including Mailpit, go -C examples/hello vet ./..., go -C examples/hello test ./..., and a focused race run for internal/build, phrasebook and postcard. Keep the script within this repository and do not invoke live SMTP accounts.</action>
<verify><automated>go test ./postcard -run 'TestMail|TestSMTPMailpit' -short -count=1 &amp;&amp; ./scripts/check-phase4.sh</automated></verify>
<acceptance_criteria>Memory and SMTP tests verify actual subject, recipients, HTML and text; all unsafe content/header cases fail; Mailpit API proves real SMTP receipt on a full run.</acceptance_criteria>
<done>scripts/check-phase4.sh exits zero with Docker available and fails rather than silently skipping a missing Docker daemon.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Test fixtures to generated source | Malicious names and imports must be rejected in public make/build paths. |
| Plugin translation/mail assets to app services | Malformed content must fail activation and unsafe values must not enter final mail. |
| SMTP client to Mailpit | A successful Send claim requires observed receipt through a separate HTTP API. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-12 | Tampering | scaffold regression tests | mitigate | Drive public make/build flow with traversal, duplicate, injected sibling import and byte-stability cases. |
| T-04-13 | Tampering | catalog regression tests | mitigate | Fail invalid YAML/plural and duplicate namespace cases with named boot diagnostics. |
| T-04-14 | Information disclosure | mail regression tests | mitigate | Assert final HTML has no unsafe links/raw tags and logs/errors contain no SMTP credentials. |
| T-04-15 | Repudiation | SMTP test result | mitigate | Require Mailpit HTTP API receipt after go-mail SMTP send; full test fails on absent Docker. |
| T-04-SC | Tampering | test image/dependencies | mitigate | Pin released Mailpit image tag and keep Go module dependencies from research; no npm/pip/cargo install. |
</threat_model>
<verification>
Run focused automated tests after each task and root go vet ./... plus go test ./... at each task commit. Final sign-off is scripts/check-phase4.sh with Docker available. A -short run is fast feedback only; it cannot substitute for the Mailpit receipt. Inspect script output for root, nested hello, race and SMTP results.
</verification>
<success_criteria>
The final gate proves CLI-02, I18N-01 and I18N-03 through generated-app compilation, catalog lookup, rendered mail, and actual SMTP receipt. High-severity threat cases have failing-when-broken tests, and full tests do not skip Docker failure.
</success_criteria>
<output>
Create .planning/phases/04-cli-scaffolding-i18n-and-mail/04-04-SUMMARY.md when done.
</output>