diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index c3aa1d7..40042da 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -883,10 +883,21 @@ Plans:
3. Plugin README and docs stay application-neutral (`the application`, example names such as `blog`).
4. The new code has unit tests, delivered in the phase's last plan.
-**Plans:** 0 plans
+**Plans:** 0/4 plans
Plans:
-- [ ] TBD (run $gsd-plan-phase 15 to break down)
+
+**Wave 1**
+- [ ] 15-01-PLAN.md — Clone plugin, squashed `golem15_journal_*` schema, Translatable, host 3-plugin boot + CORS
+
+**Wave 2** *(blocked on Wave 1 completion)*
+- [ ] 15-02-PLAN.md — Admin YAML (`mlmarkdown`), FormatHTML, permissions/nav SVG, import/export
+
+**Wave 3** *(blocked on Wave 2 completion)*
+- [ ] 15-03-PLAN.md — `/_journal/api/v1`, buckets, backend Bearer writes, media, Typesense gate off
+
+**Wave 4** *(blocked on Wave 3 completion)*
+- [ ] 15-04-PLAN.md — Unit/integration tests last, PHPUnit map, phase gate, security review
### Phase 16: grzybyfunkcjonalne.pl on reusable blog views
@@ -949,7 +960,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 14. Domain jobs and external integrations | 6/6 | In Progress| |
| 14.1. OAuth identities and fonoteka me routes (INSERTED) | 2/2 | Complete | 2026-10-05 |
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
-| 15. Journal plugin | 0/TBD | Not started | - |
+| 15. Journal plugin | 0/4 | Ready to execute | - |
| 16. grzybyfunkcjonalne.pl on reusable blog views | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index dd5086d..948498d 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,18 +1,18 @@
---
gsd_state_version: "1.0"
milestone: v1.0
-current_phase: 14.2.1
-current_phase_name: Translate plugin (INSERTED)
+current_phase: 15
+current_phase_name: journal-plugin
status: executing
stopped_at: Completed 14.2.1-06-PLAN.md
-last_updated: "2026-10-06T14:29:39.010Z"
+last_updated: "2026-10-06T16:01:39.445Z"
last_activity: 2026-10-06
-last_activity_desc: Phase 14.2.1 execution started
-state_head: ef0301631448e7e287c4f68ea3f16eb7ae2004c1
+last_activity_desc: Phase 15 planned (4 plans)
+state_head: d9f0f47e470c25226d601048a66bcceeea516148
progress:
total_phases: 26
completed_phases: 12
- total_plans: 131
+ total_plans: 135
completed_plans: 131
milestone_name: milestone
---
@@ -24,16 +24,16 @@ milestone_name: milestone
See: .planning/PROJECT.md (updated 2026-09-16)
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
-**Current focus:** Phase 14.2.1 — Translate plugin (INSERTED)
+**Current focus:** Phase 15 — Journal plugin
## Current Position
-Phase: 14.2.1 (Translate plugin (INSERTED)) — EXECUTING
-Plan: 6 of 6 complete
-Status: Ready for phase verification
-Last activity: 2026-10-06 — Completed 14.2.1-06-PLAN.md
+Phase: 15 (journal-plugin) — READY TO EXECUTE
+Plan: 0 of 4 complete
+Status: Ready to execute
+Last activity: 2026-10-06 — Phase 15 planned (4 plans)
-Progress: [██████████] 100%
+Progress: [░░░░░░░░░░] 0%
## Performance Metrics
diff --git a/.planning/phases/15-journal-plugin/15-01-PLAN.md b/.planning/phases/15-journal-plugin/15-01-PLAN.md
new file mode 100644
index 0000000..b371b68
--- /dev/null
+++ b/.planning/phases/15-journal-plugin/15-01-PLAN.md
@@ -0,0 +1,298 @@
+---
+phase: 15-journal-plugin
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - ../sm-journal-plugin/go.mod
+ - ../sm-journal-plugin/plugin.go
+ - ../sm-journal-plugin/README.md
+ - ../sm-journal-plugin/models/registry.go
+ - ../sm-journal-plugin/models/post.go
+ - ../sm-journal-plugin/models/category.go
+ - ../sm-journal-plugin/models/tag.go
+ - ../sm-journal-plugin/models/settings.go
+ - ../sm-journal-plugin/models/post_translatable_smoke_test.go
+ - ../sm-journal-plugin/updates/registry.go
+ - ../sm-journal-plugin/updates/postgres_test.go
+ - ../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go
+ - ../sm-journal-plugin/updates/202610060002_create_golem15_journal_categories.go
+ - ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go
+ - ../sm-journal-plugin/updates/202610060004_create_golem15_journal_posts_categories.go
+ - ../sm-journal-plugin/updates/202610060005_create_golem15_journal_posts_tags.go
+ - ../sm-journal-plugin/updates/202610060006_create_golem15_journal_settings.go
+ - ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go
+ - ../sm-journal-plugin/lang/en/lang.yaml
+ - ../sm-journal-plugin/lang/pl/lang.yaml
+ - ../sm-grzybyfunkcjonalne-app/summer.yaml
+ - ../sm-grzybyfunkcjonalne-app/go.work
+ - ../sm-grzybyfunkcjonalne-app/go.mod
+ - ../sm-grzybyfunkcjonalne-app/.gitmodules
+ - ../sm-grzybyfunkcjonalne-app/config/http.yaml
+ - ../sm-grzybyfunkcjonalne-app/boot_test.go
+ - ../sm-grzybyfunkcjonalne-app/plugins.gen.go
+ - ../sm-grzybyfunkcjonalne-app/main.go
+autonomous: true
+requirements: [D-01, D-02, D-03, D-04, D-06, D-07, D-09, D-10, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
+estimate:
+ tokens: 90000
+ raw_tokens: 90000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-01/D-02/D-03: Go schema and models are derived only from the read-only PHP tree at SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88."
+ - "D-04: gormigrate creates final golem15_journal_posts, golem15_journal_categories, golem15_journal_tags, golem15_journal_posts_categories, golem15_journal_posts_tags, and singleton golem15_journal_settings, plus backend_users.golem15_bloghub_author_slug."
+ - "D-09/D-10: Plugin.ID is golem15.journal, Requires is exactly golem15.translate, Post and Category implement Translatable/TranslatableIndexes/MorphName with PHP class strings."
+ - "D-18/D-19/D-20/D-21/D-22/D-23: sm-grzybyfunkcjonalne-app mounts user, translate, and journal submodules and Activate returns those three plugin IDs."
+ - "D-17: host config/http.yaml CORS paths include _journal/api/* and keep supports_credentials false."
+ - "D-06: plugin HasLang catalogs exist for en and pl only."
+ artifacts:
+ - path: "../sm-journal-plugin/plugin.go"
+ provides: "compiled plugin registration, Requires translate, migrations and models"
+ contains: "golem15.journal"
+ - path: "../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go"
+ provides: "squashed posts DDL"
+ contains: "golem15_journal_posts"
+ - path: "../sm-journal-plugin/models/post.go"
+ provides: "Post TableName, MorphName, Translatable, TranslatableIndexes"
+ contains: "Golem15\\Journal\\Models\\Post"
+ - path: "../sm-grzybyfunkcjonalne-app/summer.yaml"
+ provides: "three compiled plugins including journal"
+ contains: "golem15.journal"
+ - path: "../sm-grzybyfunkcjonalne-app/boot_test.go"
+ provides: "proof-host boot of user+translate+journal"
+ contains: "TestBootUserTranslateJournal"
+ - path: "../sm-grzybyfunkcjonalne-app/config/http.yaml"
+ provides: "D-17 CORS path for the Journal API"
+ contains: "_journal/api/*"
+ key_links:
+ - from: "../sm-journal-plugin/plugin.go"
+ to: "../sm-translate-plugin/classes/translatable.go"
+ via: "Requires golem15.translate so Post/Category Translatable storage works"
+ pattern: "golem15.translate"
+ - from: "../sm-journal-plugin/models/post.go"
+ to: "../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go"
+ via: "TableName matches squashed posts table"
+ pattern: "golem15_journal_posts"
+ - from: "../sm-grzybyfunkcjonalne-app/summer.yaml"
+ to: "../sm-grzybyfunkcjonalne-app/plugins.gen.go"
+ via: "summer build blank-imports sm-journal-plugin"
+ pattern: "sm-journal-plugin"
+ prohibitions:
+ - requirement_id: D-07
+ category: safety
+ statement: "This phase must not modify any file under the PHP journal tree at /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal"
+ status: resolved
+ verification: test
+ - requirement_id: D-09
+ category: safety
+ statement: "Journal must not port Golem15.Translate inside this plugin and must not Require an apparatus plugin ID"
+ status: resolved
+ verification: test
+ - requirement_id: D-18
+ category: architecture
+ statement: "Proof host must not be fonoteka.go, sm-summercmsio-app, or an in-module testhost"
+ status: resolved
+ verification: test
+---
+
+## Phase Goal
+
+**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
+
+This plan's slice: clone the plugin, squash the schema, persist one translatable Post, and boot the proof host with three plugins.
+
+
+Clone sm-journal-plugin, ship squashed golem15_journal_* schema and models with Translatable, and mount the plugin in sm-grzybyfunkcjonalne-app with CORS and a three-plugin boot smoke.
+
+Purpose: prove one production Post round-trip through compiled plugin + translate + Postgres + proof-host Activate before admin YAML or public HTTP.
+Output: sibling plugin repo, host gitlink/workspace/CORS, TestBootUserTranslateJournal, TestPostTranslatableSmoke.
+
+
+
+@~/.codex/gsd-core/workflows/execute-plan.md
+@~/.codex/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/15-journal-plugin/15-CONTEXT.md
+@.planning/phases/15-journal-plugin/15-RESEARCH.md
+@.planning/phases/15-journal-plugin/15-PATTERNS.md
+@.planning/notes/core-plugins-own-repos.md
+@../sm-translate-plugin/go.mod
+@../sm-translate-plugin/plugin.go
+@../sm-grzybyfunkcjonalne-app/summer.yaml
+@../sm-grzybyfunkcjonalne-app/boot_test.go
+
+
+## Spec-less probe fallback
+
+Phase 15 has no mapped REQUIREMENTS.md IDs (ROADMAP lists TBD). Spec-less requirement probing is a visible skip this run; no probe predicates are generated. Acceptance is D-01 through D-23 plus RESEARCH validation rows. Do not claim API-09 or QA-05 (Phase 20).
+
+## Schema push gate
+
+Skipped: this phase does not touch Prisma, Drizzle, Payload, TypeORM, or Supabase schema files. Journal DDL is plugin gormigrate (DATA-02). D-11 is not a new field type.
+
+## Assumption-delta decision
+
+
+signal: optional
+term: optional editor / optional Typesense
+decision: no-change
+rationale: Public GET stays anonymous with an optional backend principal overlay copied from PHP; writes still require backend JWT. Typesense remains a settings kill-switch defaulting off. The identity primary does not move.
+
+
+## Artifacts this phase produces
+
+- Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, `Plugin.ID() == "golem15.journal"`, `Requires() == []string{"golem15.translate"}`.
+- Squashed gormigrate IDs `202610060001` through `202610060007` for the six journal tables plus `backend_users.golem15_bloghub_author_slug`.
+- `models.Post`, `models.Category`, `models.Tag`, `models.Settings` with Fillable/MorphName/Translatable as RESEARCH §1–§2.
+- Proof-host submodule at `plugins/golem15/journal`, `go.work` use, go.mod require+replace, CORS `_journal/api/*`, `TestBootUserTranslateJournal`.
+- Application-neutral plugin README (`the application`, example `blog`).
+
+
+
+
+ Task 1: Clone the plugin, persist one en/pl Post title, and boot the host with three plugins
+ D-18/D-22/D-23 submodule path plugins/golem15/journal and module git.golem15.com/golem15/sm-journal-plugin become the durable layout; moving them later needs coordinated gitlink and workspace changes. Locked in CONTEXT — do not re-ask.
+ git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git and git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git succeed; PHP journal HEAD is 02110eb1c0c3861370b0b9b47b209a0702ac5d88; ../sm-grzybyfunkcjonalne-app already mounts user and translate.
+ ../sm-journal-plugin/go.mod, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post_translatable_smoke_test.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/updates/postgres_test.go, ../sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/main.go
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-01 through D-04, D-09, D-10, D-18 through D-23), .planning/phases/15-journal-plugin/15-RESEARCH.md (Verified PHP pin, Tables, Translatable, Host wiring, Pitfall 8 and Pitfall 9), .planning/phases/15-journal-plugin/15-PATTERNS.md (JR/go.mod, plugin.go, post.go, updates, Proof host), .planning/notes/core-plugins-own-repos.md, ../sm-translate-plugin/go.mod, ../sm-translate-plugin/plugin.go, ../sm-translate-plugin/updates/202610060001_create_golem15_translate_locales.go, ../sm-translate-plugin/updates/postgres_test.go, ../sm-translate-plugin/classes/translatable.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/boot_test.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php
+ Clone git@git.golem15.com:golem15/sm-journal-plugin.git into /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin (D-22, D-23). Read PHP only from /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal at SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88 (D-01, D-02, D-03); do not clone a second PHP tree and do not edit that tree (D-07).
+
+Create module git.golem15.com/golem15/sm-journal-plugin, package journal, Go 1.27.0. Require summercms, gormigrate v2.1.7, gorm v1.31.2, and the same testcontainers pins translate uses. Sibling replace git.golem15.com/golem15/summercms => ../summercms.go (pitfall 9). Do not require sm-user-plugin. Do not add undecided libraries.
+
+Register party.Plugin in init. ID golem15.journal. Requires exactly golem15.translate (D-09). Do not Require apparatus. Implement HasMigrations and HasModels. Copy translate's TestMain/testcontainers harness into updates/postgres_test.go so isolated plugin tests run against real Postgres and treat missing Docker as failure unless -short.
+
+Ship gormigrate ID 202610060001_create_golem15_journal_posts with explicit CREATE/INDEX/UNIQUE for RESEARCH §1 posts columns (id, user_id, redactor_id, title, slug unique, excerpt, content, content_html, published_at, published default false, is_pinned default false, metadata JSONB, sources JSONB, nullable timestamps). Rollback DROP TABLE. Never schema auto-sync. Squash to final golem15_journal_* names only.
+
+Implement models.Post: TableName golem15_journal_posts; MorphName the PHP class string Golem15\Journal\Models\Post (D-10, pitfall 10); Translatable title, content, content_html, excerpt, metadata; TranslatableIndexes slug. Admin/API Fillable later; this tracer must not mass-assign redactor_id, content_html, or user_id from a map. jsonable metadata and sources as JSONB.
+
+Smoke TestPostTranslatableSmoke: migrate translate then journal (D-19 migrations run), seed en/pl via translate migrations, insert one Post, SetTranslated English on host columns and Polish title/slug through translate helpers, read Polish back. This is the production tracer, not the Plan 04 matrix.
+
+On the D-18 host at D-20, add gitlink plugins/golem15/journal to the journal remote, go.work use ./plugins/golem15/journal, go.mod require+replace to ./plugins/golem15/journal, summer.yaml id golem15.journal module git.golem15.com/golem15/sm-journal-plugin after translate (D-21). Regenerate plugins.gen.go and main.go with summer build (build ./cmd/summer from this repo into a temp binary, run it with cwd the host); do not hand-author those files after the first generation.
+
+Rename/extend host TestBootUserTranslate to TestBootUserTranslateJournal: len(plugins)==3, IDs golem15.user then golem15.translate then golem15.journal (or topological equivalent that includes all three), journal is allowed in PluginIDs, migrations non-empty for journal. Remove the production-list exclusion of the journal plugin ID (pitfall 8). Do not yet assert admin controller IDs or /_journal/api/v1 routes (Plans 02 and 03).
+
+ git ls-remote git@git.golem15.com:golem15/sm-journal-plugin.git && git -C /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal rev-parse HEAD && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostTranslatableSmoke)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'
+ Non-zero exit; PHP SHA is not 02110eb1c0c3861370b0b9b47b209a0702ac5d88; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent; journal remote is missing.
+
+
+ - Local checkout exists at ../sm-journal-plugin with origin git@git.golem15.com:golem15/sm-journal-plugin.git.
+ - Plugin module/package/ID match D-22 and Requires is exactly golem15.translate.
+ - Migration 202610060001 creates golem15_journal_posts; plugin source does not create rainlab-era journal table names.
+ - Post.MorphName is Golem15\Journal\Models\Post; TranslatableIndexes contains slug.
+ - TestPostTranslatableSmoke stores English on the host row and Polish in golem15_translate_attributes.
+ - Host summer.yaml, go.work, go.mod replace, .gitmodules, and plugins.gen.go all name sm-journal-plugin / golem15.journal.
+ - TestBootUserTranslateJournal activates three plugins including golem15.journal.
+ - git diff -- /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal is empty.
+
+ A compiled journal plugin persists one translatable Post and the proof host boots user, translate, and journal.
+
+
+
+ Task 2: Add categories, tags, pivots, settings singleton, and author_slug
+ backend_users.golem15_bloghub_author_slug is a plugin-owned ALTER of a framework table; dropping it later needs a coordinated rollback in every host that migrated Journal.
+ ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/tag.go, ../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/updates/202610060002_create_golem15_journal_categories.go, ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go, ../sm-journal-plugin/updates/202610060004_create_golem15_journal_posts_categories.go, ../sm-journal-plugin/updates/202610060005_create_golem15_journal_posts_tags.go, ../sm-journal-plugin/updates/202610060006_create_golem15_journal_settings.go, ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go
+ .planning/phases/15-journal-plugin/15-RESEARCH.md (Tables, Fillable, Settings singleton, companion author_slug, assumption A4), .planning/phases/15-journal-plugin/15-PATTERNS.md (category.go, tag.go, settings.go, updates table), ../sm-translate-plugin/updates/registry.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go, modules/cabana/contracts.go (BackendUser TableName), modules/cabana/example_controller_test.go (BlogSettings), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Tag.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Settings.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/settings/fields.yaml
+ Expand from the proven posts table. Add gormigrate IDs 202610060002 through 202610060007 registered in updates.All in ID order (D-04).
+
+Categories: columns id, name, slug unique indexed, code nullable, description, parent_id indexed nullable, nest_left, nest_right, nest_depth, timestamps. Keep nest_* in DDL; no NestedTree reorder UI this phase. Category Fillable only name, slug, code, description, parent_id (JOURNAL-002 — nest_* not fillable). Translatable name, description; TranslatableIndexes slug; MorphName Golem15\Journal\Models\Category.
+
+Tags: id, name, slug unique indexed, description, timestamps. Fillable only name, slug, description (JOURNAL-001). Tag is not translatable.
+
+Pivots golem15_journal_posts_categories and golem15_journal_posts_tags with the PHP pair of FKs. Post belongsToMany categories and tags through those tables. Post belongsTo cabana.BackendUser on user_id. redactor_id is a naked integer with no sm-user-plugin import.
+
+Settings: dedicated table golem15_journal_settings ID=1, not PHP system_settings. Typed columns from Settings.php rules plus fields.yaml: show_all_posts bool default true, use_rich_editor bool default false, search_use_typesense bool default false, search_title_weight int default 5, search_excerpt_weight int default 3, search_content_weight int default 1, rss_enabled bool default true, rss_include_content bool default false, rss_title, rss_description, rss_language default en-us, rss_copyright, rss_image_url, rss_posts_per_feed int default 20. Fillable those columns; Rules match PHP. use_rich_editor is stored for later; compile-time ignore is Plan 02.
+
+Author slug: ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE. Do not put this column in lagoon framework migrations. Rollback drops the column only if safe (IF EXISTS).
+
+Add TestJournalTables that migrates the journal set and asserts the six golem15_journal_* table names plus backend_users.golem15_bloghub_author_slug (D-04). Do not seed posts. Seed Uncategorized only if the frozen PHP seeder at this pin still inserts it (A4); RESEARCH grep found none — skip seed rows.
+
+AttachRelations on Post for featured_images and content_images as attachMany using the same MorphName PHP class string and system_files. Do not import sm-user-plugin to type redactor.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./updates -count=1 -v -run '^(TestJournalTables)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalTables".
+
+
+ - After migrate, information_schema lists golem15_journal_posts, golem15_journal_categories, golem15_journal_tags, both pivot tables, and golem15_journal_settings.
+ - backend_users has golem15_bloghub_author_slug.
+ - Category.Fillable is exactly name, slug, code, description, parent_id.
+ - Tag.Fillable is exactly name, slug, description.
+ - Settings table defaults search_use_typesense to false (D-12 storage ready).
+ - No rainlab-era journal table names appear in updates/*.go.
+ - models package does not import sm-user-plugin.
+
+ All D-04 tables exist and Category/Tag/Settings fillable boundaries match the PHP pin.
+
+
+
+ Task 3: Add en/pl phrasebook, neutral README, and host CORS
+ ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/README.md, ../sm-grzybyfunkcjonalne-app/config/http.yaml
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-06, D-17), .planning/phases/15-journal-plugin/15-RESEARCH.md (CORS, phrasebook, Pitfall 13), .planning/phases/15-journal-plugin/15-PATTERNS.md (Phrasebook + README, Proof host CORS), ../sm-translate-plugin/lang/en/lang.yaml, ../sm-translate-plugin/README.md, ../sm-grzybyfunkcjonalne-app/config/http.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/lang/en/lang.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/lang/pl/lang.php, CLAUDE.md Documentation section
+ Implement pact.HasLang. Port plugin.*, journal.*, post.*, category.*, tag.* UI keys from PHP lang/en/lang.php and lang/pl/lang.php into lang/en/lang.yaml and lang/pl/lang.yaml (D-06). Do not add the other 19 PHP locales. Phrasebook catalogs are UI strings, not model translation JSON.
+
+Write plugin README following translate: H1 name, one-sentence summary, import line, Overview, Features, Usage, API reference, Dependencies, Testing. Say the application or host application; example names blog and acme. Do not name the proof host or Płytarium (pitfall 13). Document MorphName PHP class strings. Note Winter component pages (journalPost, journalPosts, and similar) as a Phase 16 successor, not implemented here.
+
+Add CORS path _journal/api/* to host config/http.yaml alongside existing _user/api/* (D-17). Keep supports_credentials false. Do not invent handler-level CORS headers.
+
+Host README may name itself; plugin README must not.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && python3 -c 'import pathlib,sys; t=pathlib.Path("../sm-journal-plugin/README.md").read_text(); bad=["grzybyfunkcjonalne","Płytarium","fonoteka.go","plytarium"];
+sys.exit(1 if any(b.lower() in t.lower() for b in bad) else 0)' && grep -F "_journal/api/*" ../sm-grzybyfunkcjonalne-app/config/http.yaml && test -f ../sm-journal-plugin/lang/en/lang.yaml && test -f ../sm-journal-plugin/lang/pl/lang.yaml
+ Non-zero exit; plugin tests FAIL; README python check exits 1; grep does not print _journal/api/*; either lang YAML is missing.
+
+
+ - lang/en/lang.yaml and lang/pl/lang.yaml exist and plugin LangFS embeds them.
+ - No lang directory other than en and pl is added.
+ - Plugin README uses the application / blog and does not name the proof host or Płytarium.
+ - Host http.yaml CORS paths include both _user/api/* and _journal/api/*.
+ - supports_credentials remains false.
+
+ Operators have en/pl UI catalogs, a neutral plugin README, and host CORS ready for /_journal/api/v1 in Plan 03.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Plugin gormigrate → Postgres | Persistent blog schema and a column on backend_users |
+| Host gitlinks → compiled binary | Remote plugin commits become trusted build inputs |
+| Model Fillable → GORM | Untrusted maps must not set redactor_id, nest_*, user_id |
+| PHP tree → planner/executor | Frozen contract is read-only |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-15-04 | Elevation of Privilege | Category/Tag Fillable | high | mitigate | Allow-lists name/slug/description (+ category code, parent_id); nest_* and redactor_id excluded; tests in Plan 04 |
+| T-15-05 | Tampering | gormigrate DDL | high | mitigate | Explicit CREATE/ALTER from frozen SHA; no schema auto-sync; squash to golem15_journal_* only |
+| T-15-06 | Tampering | MorphName | high | mitigate | Hard-code PHP class strings; never reflect.Type.String; smoke writes translate attributes under that morph |
+| T-15-SC | Tampering | package installs | high | mitigate | No new external packages; goldmark wait until Plan 02 FormatHTML if imported |
+
+ASVS L1: high threats are blocked by implementation and become fail-closed tests in Plan 04. T-15-01..T-15-03 are Journal HTTP threats owned by Plan 03 (VALIDATION map).
+
+
+
+Run the three task commands. Confirm PHP journal git diff is empty. Confirm go -C ../sm-journal-plugin vet ./... and host TestBootUserTranslateJournal pass.
+
+
+
+- Plugin repo exists at D-23 with compiling module and squashed tables.
+- One Post en/pl round-trip works through translate storage.
+- Proof host Activate returns three plugins including golem15.journal.
+- CORS path _journal/api/* is present.
+- No PHP edits, no apparatus Require, no extra locales, no admin YAML or public routes yet.
+
+
+
diff --git a/.planning/phases/15-journal-plugin/15-02-PLAN.md b/.planning/phases/15-journal-plugin/15-02-PLAN.md
new file mode 100644
index 0000000..20bb422
--- /dev/null
+++ b/.planning/phases/15-journal-plugin/15-02-PLAN.md
@@ -0,0 +1,267 @@
+---
+phase: 15-journal-plugin
+plan: 02
+type: execute
+wave: 2
+depends_on: ["15-01"]
+files_modified:
+ - ../sm-journal-plugin/plugin.go
+ - ../sm-journal-plugin/admin.go
+ - ../sm-journal-plugin/admin_permissions.go
+ - ../sm-journal-plugin/admin_navigation.go
+ - ../sm-journal-plugin/assets/images/journal-icon.svg
+ - ../sm-journal-plugin/classes/format_html.go
+ - ../sm-journal-plugin/controllers/admin_registry.go
+ - ../sm-journal-plugin/controllers/posts.go
+ - ../sm-journal-plugin/controllers/categories.go
+ - ../sm-journal-plugin/controllers/tags.go
+ - ../sm-journal-plugin/controllers/posts/config_list.yaml
+ - ../sm-journal-plugin/controllers/posts/config_form.yaml
+ - ../sm-journal-plugin/controllers/posts/config_filter.yaml
+ - ../sm-journal-plugin/controllers/categories/config_list.yaml
+ - ../sm-journal-plugin/controllers/categories/config_form.yaml
+ - ../sm-journal-plugin/controllers/tags/config_list.yaml
+ - ../sm-journal-plugin/controllers/tags/config_form.yaml
+ - ../sm-journal-plugin/models/post.go
+ - ../sm-journal-plugin/models/post/fields.yaml
+ - ../sm-journal-plugin/models/post/columns.yaml
+ - ../sm-journal-plugin/models/category/fields.yaml
+ - ../sm-journal-plugin/models/category/columns.yaml
+ - ../sm-journal-plugin/models/tag/fields.yaml
+ - ../sm-journal-plugin/models/tag/columns.yaml
+ - ../sm-journal-plugin/models/settings.go
+ - ../sm-journal-plugin/models/settings/fields.yaml
+ - ../sm-journal-plugin/console/export_posts.go
+ - ../sm-journal-plugin/console/import_posts.go
+ - ../sm-journal-plugin/posts_admin_smoke_test.go
+ - ../sm-journal-plugin/go.mod
+ - ../sm-journal-plugin/README.md
+ - ../sm-grzybyfunkcjonalne-app/boot_test.go
+autonomous: true
+requirements: [D-04, D-06, D-08, D-10, D-11, D-13]
+estimate:
+ tokens: 100000
+ raw_tokens: 100000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-04/D-08: Posts, Categories, and Tags admin controllers are YAML-driven, permission-gated, and the nav item embeds assets/images/journal-icon.svg."
+ - "D-11: post content YAML type is mlmarkdown; cabana markdown/mltext/mlmarkdown already shipped in 14.2.1 and are not re-added."
+ - "D-10: title, slug, excerpt, content use mltext/mlmarkdown so translatable attributes stay translatable."
+ - "D-13: journal:export-posts and journal:import-posts are registered, and Posts toolbar actions require golem15.journal.access_import_export."
+ - "FormatHTML regenerates content_html on admin save using goldmark footnote/table/attribute extensions plus cabana's rejectUnsafe gate, without editing cabana.RenderMarkdown."
+ - "Without golem15.journal.access_other_posts, list/form queries restrict to user_id of the backend principal; publish writes without golem15.journal.access_publish return ForbiddenError."
+ artifacts:
+ - path: "../sm-journal-plugin/controllers/posts.go"
+ provides: "golem15.journal.posts controller, ListExtendQuery, FormBeforeCreate, FormatHTML hook"
+ contains: "golem15.journal.access_posts"
+ - path: "../sm-journal-plugin/models/post/fields.yaml"
+ provides: "adapted cabana-legal post form including mlmarkdown content"
+ contains: "mlmarkdown"
+ - path: "../sm-journal-plugin/assets/images/journal-icon.svg"
+ provides: "embedded admin nav SVG (D-08)"
+ contains: "svg"
+ - path: "../sm-journal-plugin/classes/format_html.go"
+ provides: "journal.FormatHTML"
+ contains: "func FormatHTML"
+ - path: "../sm-journal-plugin/console/export_posts.go"
+ provides: "journal:export-posts"
+ contains: "journal:export-posts"
+ - path: "../sm-journal-plugin/models/settings/fields.yaml"
+ provides: "HasSettings code journal"
+ contains: "search_use_typesense"
+ key_links:
+ - from: "../sm-journal-plugin/controllers/posts.go"
+ to: "../sm-journal-plugin/classes/format_html.go"
+ via: "FormBeforeCreate/Update regenerate content_html"
+ pattern: "FormatHTML"
+ - from: "../sm-journal-plugin/admin_permissions.go"
+ to: "../sm-journal-plugin/controllers/posts.go"
+ via: "RequiredPermissions golem15.journal.access_posts"
+ pattern: "access_posts"
+ - from: "../sm-journal-plugin/console/export_posts.go"
+ to: "../sm-journal-plugin/plugin.go"
+ via: "HasCommands registers PHP command names"
+ pattern: "journal:export-posts"
+ prohibitions:
+ - requirement_id: D-11
+ category: architecture
+ statement: "Do not add a second cabana YAML field type and do not edit modules/cabana/field_markdown.go or cabana.RenderMarkdown"
+ status: resolved
+ verification: test
+ - requirement_id: D-13
+ category: architecture
+ statement: "Do not port Winter.Pages menu item types or an admin dashboard report widget"
+ status: resolved
+ verification: test
+ - requirement_id: D-06
+ category: privacy
+ statement: "Do not ship the remaining 19 PHP locales beyond en and pl"
+ status: resolved
+ verification: test
+---
+
+## Phase Goal
+
+**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
+
+This plan's slice: an administrator with `golem15.journal.*` can create a Post whose `mlmarkdown` content is stored and whose `content_html` is regenerated through plugin FormatHTML.
+
+
+Ship YAML admin for Posts, Categories, Tags, and Settings, embed the nav SVG, implement FormatHTML, and register CSV import/export CLI plus toolbar actions.
+
+Purpose: operators can manage Journal content in the existing admin SPA without PHP widgets or illegal YAML keys.
+Output: adapted YAML, permissions, navigation, FormatHTML, commands, Posts admin smoke.
+
+
+
+@~/.codex/gsd-core/workflows/execute-plan.md
+@~/.codex/gsd-core/templates/summary.md
+
+
+
+@.planning/phases/15-journal-plugin/15-CONTEXT.md
+@.planning/phases/15-journal-plugin/15-RESEARCH.md
+@.planning/phases/15-journal-plugin/15-PATTERNS.md
+@../sm-journal-plugin/plugin.go
+@../fonoteka.go/plugins/golem15/user/admin.go
+@../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
+@modules/cabana/form_schema.go
+@modules/cabana/field_markdown.go
+@docs/backend/forms.md
+
+
+## Spec-less probe fallback
+
+Visible skip: no REQUIREMENTS.md IDs. D-04/D-06/D-08/D-10/D-11/D-13 and RESEARCH §3–§4 are the contract. D-11 is a no-op field-type addition: Journal uses existing mlmarkdown.
+
+## Artifacts this phase produces
+
+- Controllers `golem15.journal.posts|categories|tags`, Settings code `journal`, permission codes copied from PHP Plugin.php 55-90.
+- Adapted fields/columns/list/filter YAML using only cabana-legal types and keys.
+- Embedded `assets/images/journal-icon.svg`.
+- `journal.FormatHTML` in the plugin; goldmark v1.8.6 required only if this file imports it.
+- Commands `journal:export-posts` / `journal:import-posts` and Posts toolbar actions gated by `golem15.journal.access_import_export`.
+
+
+
+
+ Task 1: Create one Post through cabana with mlmarkdown and FormatHTML
+ D-10/D-11 couple post content to cabana mlmarkdown and translate nested maps; changing the YAML type later is an admin contract change.
+ ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/admin.go, ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/admin_navigation.go, ../sm-journal-plugin/assets/images/journal-icon.svg, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts/config_list.yaml, ../sm-journal-plugin/controllers/posts/config_form.yaml, ../sm-journal-plugin/controllers/posts/config_filter.yaml, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/models/post/columns.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/go.mod
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-08, D-10, D-11), .planning/phases/15-journal-plugin/15-RESEARCH.md (§3 YAML rewrite, §4 D-11 already shipped, FormatHTML, permissions), .planning/phases/15-journal-plugin/15-PATTERNS.md (admin.go, posts controller, YAML rewrite table, format_html.go), ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, modules/cabana/form_schema.go (formFieldTypes and allowed keys), modules/cabana/filter_schema.go, modules/cabana/field_markdown.go (rejectUnsafeMarkdownHTML), docs/backend/forms.md (mltext/mlmarkdown), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/post/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Posts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/assets/images/journal-icon.svg, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (formatHtml, canEdit, filterFields, beforeSave)
+ Implement HasAdminControllers, HasPermissions, HasNavigation. Embed YAML file-by-file (not the whole controllers directory). Copy permission codes exactly: golem15.journal.manage_settings, access_posts, access_categories, access_other_posts, access_import_export, access_publish, access_tags. Roles developer like the user plugin. cabana.Allows is OR.
+
+Controller ID golem15.journal.posts, ModelName Golem15\Journal\Models\Post, ConfigDir controllers/posts, RequiredPermissions golem15.journal.access_posts. ListExtendQuery and FormExtendQuery: without access_other_posts, where user_id equals bouncer.User principal ID. FormBeforeCreate stamps user_id from the backend principal when empty. Publish writes (published true or published_at set) without access_publish return cabana.ForbiddenError — hiding fields is optional UX; refuse is mandatory.
+
+Rewrite post fields.yaml to cabana-legal keys only (D-04, D-10, D-11). title mltext; slug mltext plus preset field title type slug; content mlmarkdown (not a PHP form widget class); excerpt mltext; categories relation nameFrom name; tags relation nameFrom name (create tags on the Tags admin, not an on-the-fly list widget); published switch; is_pinned checkbox; user relation nameFrom login emptyOption current user; published_at datepicker mode datetime; featured_images fileupload mode image imageWidth/imageHeight 200. Omit the sources repeater field from the admin form (keep JSONB; API still accepts sources in Plan 03). Omit metadata preview_page (Phase 16). Omit toolbar partial. Drop PHP keys cabana refuses (stretch, cssClass, commentAbove, widget class, paneCssClass). D-11 is already shipped in 14.2.1 — do not add a YAML type and do not edit modules/cabana.
+
+config_filter.yaml: no raw SQL condition keys (cabana boot-fails those). published switch via FilterPublished; published_date daterange column created_at; category scope FilterCategories including child categories as PHP does. Implement FilterScopes on Post. List columns may use type date. recordsPerPage 25. recordUrl golem15/journal/posts/update/:id.
+
+Copy PHP assets/images/journal-icon.svg bytes into the Go plugin (D-08). Navigation Code journal, Permissions golem15.journal.*, Order 300, Controller golem15.journal.posts, icon pencil or icon-pencil (existing lucide map). Side menu new_post, posts, categories, tags with PHP permission lists. Skip dashboard widget.
+
+Implement journal.FormatHTML in classes/format_html.go: goldmark with footnote, table, and parser attribute extensions from the existing github.com/yuin/goldmark module (A1 — if a separate module is required, stop and do not add it). Reuse the same rejectUnsafeMarkdownHTML checks as cabana (script, iframe, event handlers, javascript/vbscript/data schemes). Do not change cabana.RenderMarkdown (pitfall 15). If FormatHTML imports goldmark, require github.com/yuin/goldmark v1.8.6 in plugin go.mod. Call FormatHTML from FormBeforeCreate and FormBeforeUpdate so stored content_html matches admin saves, not only API writes. Ignore use_rich_editor when compiling the form (always mlmarkdown; WYSIWYG deferred).
+
+Smoke TestPostsFormCompiles / TestPostsAdminCreateSmoke: cabana.Activate compiles the posts form; a privileged backend principal creates a post with nested en/pl title and content maps; English lands on host columns; Polish reaches translate attributes; content_html is non-empty and contains no script tags. Full PHPUnit map stays Plan 04.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestPostsFormCompiles|TestPostsAdminCreateSmoke)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.
+
+
+ - models/post/fields.yaml contains type mlmarkdown for content and type mltext for title, slug, excerpt.
+ - models/post/fields.yaml does not use PHP widget class names, on-the-fly tag widgets, or source-repeater fields.
+ - controllers/posts/config_filter.yaml compiles and does not use cabana-illegal filter keys.
+ - assets/images/journal-icon.svg is embedded and byte-comparable to the PHP SVG.
+ - FormatHTML lives in the plugin; modules/cabana/field_markdown.go is unmodified in this plan.
+ - Creating a post without access_publish cannot persist published=true.
+ - Host/plugin README still does not name a consuming application.
+
+ An administrator can create a translatable markdown Post through cabana, with content_html regenerated by plugin FormatHTML.
+
+
+
+ Task 2: Categories, Tags, Settings screens and remaining query guards
+ ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/tags.go, ../sm-journal-plugin/controllers/categories/config_list.yaml, ../sm-journal-plugin/controllers/categories/config_form.yaml, ../sm-journal-plugin/controllers/tags/config_list.yaml, ../sm-journal-plugin/controllers/tags/config_form.yaml, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/models/category/columns.yaml, ../sm-journal-plugin/models/tag/fields.yaml, ../sm-journal-plugin/models/tag/columns.yaml, ../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/settings/fields.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/admin.go, ../sm-grzybyfunkcjonalne-app/boot_test.go
+ .planning/phases/15-journal-plugin/15-PATTERNS.md (categories/tags controllers, settings analog, YAML), ../sm-translate-plugin/controllers/locales.go, ../sm-translate-plugin/models/locale/fields.yaml, modules/cabana/example_controller_test.go (Settings), docs/backend/settings.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Categories.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/Tags.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/category/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/tag/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/settings/fields.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go
+ Add controllers golem15.journal.categories (access_categories) and golem15.journal.tags (access_tags). Category parent_id is a relation; keep nest_* off the form. Tag fields name, slug, description only.
+
+HasSettings item Code journal, Permissions golem15.journal.manage_settings, Form models/settings/fields.yaml, NewModel Settings. Rewrite settings YAML: drop every show/hide trigger block so search weight fields always display; drop placeholder keys (use comment). Keep use_rich_editor stored; do not switch the post editor off mlmarkdown.
+
+Extend host TestBootUserTranslateJournal to assert controller IDs golem15.journal.posts, golem15.journal.categories, golem15.journal.tags and settings code journal are registered. Still do not require /_journal/api/v1 routes (Plan 03).
+
+canEdit on Post: owner or access_other_posts. Use it in FormExtendQuery/update/delete paths so a second admin without access_other_posts cannot open another author's post.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1 && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; host run lacks "--- PASS: TestBootUserTranslateJournal".
+
+
+ - AdminControllers include golem15.journal.posts, golem15.journal.categories, golem15.journal.tags.
+ - Settings() includes Code journal with manage_settings.
+ - models/settings/fields.yaml has search_use_typesense default off and always-visible weight fields.
+ - Host boot test asserts the three Journal controller IDs.
+ - Category form has no nest_left field.
+
+ All three Journal admin screens and the settings singleton are registered and boot in the proof host.
+
+
+
+ Task 3: Register journal:export-posts and journal:import-posts plus Posts toolbar
+ ../sm-journal-plugin/console/export_posts.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/README.md
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-13), .planning/phases/15-journal-plugin/15-RESEARCH.md (Import/export), .planning/phases/15-journal-plugin/15-PATTERNS.md (console, HasAdminActions), ../fonoteka.go/plugins/golem15/user/console/require_password_change.go, modules/pact/capabilities.go (HasCommands, HasAdminActions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/console/ExportPosts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/console/ImportPosts.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/PostExport.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/PostImport.php
+ Add TestJournalCommands that asserts Commands() contains journal:export-posts and journal:import-posts. Implement pact.HasCommands. Names journal:export-posts and journal:import-posts matching PHP Plugin.php 169-170 (D-13). Port PostExport/PostImport column sets; flags --path and --dry-run as PHP. Do not invent a generic CSV framework. Do not add Winter ImportExport behavior.
+
+On Posts, HasAdminActions toolbar buttons with Permissions golem15.journal.access_import_export. Names must not be create or delete (reserved). Actions call the same import/export column logic as the CLI.
+
+Document the two command names in the plugin README. Keep examples as blog. Do not implement Pages menu types or a dashboard widget.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalCommands)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; lacks "--- PASS: TestJournalCommands".
+
+
+ - Plugin Commands() includes journal:export-posts and journal:import-posts.
+ - Posts AdminActions include import/export names whose Permissions contain golem15.journal.access_import_export.
+ - README documents both command names.
+ - No Pages menu-type registration and no dashboard widget type appear in plugin.go or admin_navigation.go.
+
+ CSV import/export is available from CLI and from the Posts toolbar for operators holding access_import_export.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin JSON → cabana CRUD | Untrusted form maps cross permission and Fillable |
+| Markdown source → content_html | Stored HTML can carry active markup |
+| Toolbar/CLI → posts table | Import must not bypass fillable or publish permission |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-15-07 | Elevation of Privilege | Posts admin | high | mitigate | RequiredPermissions, ListExtendQuery owner scope, access_other_posts, 403 smoke in Plan 04 |
+| T-15-08 | Tampering | FormatHTML | high | mitigate | rejectUnsafe on stored HTML; do not enable unsafe goldmark HTML globally |
+| T-15-09 | Elevation of Privilege | access_publish | high | mitigate | ForbiddenError on publish writes without grant |
+| T-15-SC | Tampering | package installs | high | mitigate | goldmark v1.8.6 only if FormatHTML imports it; already in the framework graph |
+
+ASVS L1: high threats mitigated here; fail-closed tests in Plan 04.
+
+
+
+Run all three task commands, then go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -short -count=1.
+
+
+
+- Posts form compiles with mlmarkdown and an admin can create a post.
+- Categories, Tags, and Settings are registered.
+- FormatHTML is plugin-local; cabana.RenderMarkdown unchanged.
+- Import/export commands and toolbar exist.
+- D-11 adds no new field type. Pages/dashboard/extra locales remain absent.
+
+
+
diff --git a/.planning/phases/15-journal-plugin/15-03-PLAN.md b/.planning/phases/15-journal-plugin/15-03-PLAN.md
new file mode 100644
index 0000000..7a11e9b
--- /dev/null
+++ b/.planning/phases/15-journal-plugin/15-03-PLAN.md
@@ -0,0 +1,263 @@
+---
+phase: 15-journal-plugin
+plan: 03
+type: execute
+wave: 3
+depends_on: ["15-02"]
+files_modified:
+ - ../sm-journal-plugin/plugin.go
+ - ../sm-journal-plugin/routes.go
+ - ../sm-journal-plugin/controllers/api/posts.go
+ - ../sm-journal-plugin/controllers/api/posts_test.go
+ - ../sm-journal-plugin/controllers/api/media.go
+ - ../sm-journal-plugin/controllers/api/auth.go
+ - ../sm-journal-plugin/search.go
+ - ../sm-journal-plugin/models/post.go
+ - ../sm-journal-plugin/README.md
+ - ../sm-journal-plugin/journal_public_list_smoke_test.go
+ - ../sm-grzybyfunkcjonalne-app/boot_test.go
+autonomous: true
+requirements: [D-12, D-14, D-15, D-16, D-17]
+estimate:
+ tokens: 110000
+ raw_tokens: 110000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-14: anonymous GET /_journal/api/v1/posts, posts/{slug}, categories, tags, and rss work; writes use the same prefix."
+ - "D-15: POST/PUT/DELETE posts, featured-images, and media/upload require a cabana backend JWT (audience backend); missing Bearer returns JSON {error:Authentication required}."
+ - "D-17: buckets journal-public-api and journal-api are Max 120 per minute; 429 body stays surf Too Many Attempts."
+ - "JOURNAL-005: unpublished or future published_at show is 404 with no data key unless owner or access_other_posts."
+ - "D-12: search_use_typesense defaults false; Post ShouldBeSearchable is false when unpublished or the beachcomber Gate is off; a fresh save makes zero Typesense HTTP."
+ - "routes.php wins: show is GET posts/{slug}; list per_page default 9 max 30."
+ artifacts:
+ - path: "../sm-journal-plugin/routes.go"
+ provides: "public and write groups under /_journal/api/v1"
+ contains: "/_journal/api/v1"
+ - path: "../sm-journal-plugin/controllers/api/posts.go"
+ provides: "PHP {error} string JSON, slug show, draft 404"
+ contains: "Authentication required"
+ - path: "../sm-journal-plugin/controllers/api/media.go"
+ provides: "JOURNAL-006 media upload under journal/ prefix"
+ contains: "media/upload"
+ - path: "../sm-journal-plugin/plugin.go"
+ provides: "Buckets journal-public-api and journal-api"
+ contains: "journal-public-api"
+ - path: "../sm-journal-plugin/search.go"
+ provides: "beachcomber Gate on golem15_journal_settings.search_use_typesense"
+ contains: "search_use_typesense"
+ key_links:
+ - from: "../sm-journal-plugin/routes.go"
+ to: "../sm-journal-plugin/controllers/api/auth.go"
+ via: "writes authenticate backend JWT in-handler; public GET optionally verifies Bearer"
+ pattern: "backend"
+ - from: "../sm-journal-plugin/controllers/api/posts.go"
+ to: "../sm-journal-plugin/classes/format_html.go"
+ via: "store/update regenerate content_html"
+ pattern: "FormatHTML"
+ - from: "../sm-journal-plugin/search.go"
+ to: "../sm-journal-plugin/models/settings.go"
+ via: "Gate reads ID=1 search_use_typesense; errors count as off"
+ pattern: "SetGate"
+ prohibitions:
+ - requirement_id: D-15
+ category: safety
+ statement: "Write routes must not accept frontend sm-user-plugin personal tokens and must not accept Apparatus personal tokens"
+ status: resolved
+ verification: test
+ - requirement_id: D-14
+ category: architecture
+ statement: "Journal API errors must stay flat PHP {error} string JSON and must not use the cabana admin error envelope"
+ status: resolved
+ verification: test
+ - requirement_id: D-12
+ category: safety
+ statement: "A fresh install must not contact Typesense; search_use_typesense stays false by default"
+ status: resolved
+ verification: test
+ - requirement_id: D-16
+ category: architecture
+ statement: "Journal must not be added to the Płytarium tide 154-route harness"
+ status: resolved
+ verification: test
+---
+
+## Phase Goal
+
+**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
+
+This plan's slice: anonymous GET `/_journal/api/v1/posts` returns published posts, and a backend-Bearer POST creates one.
+
+
+Ship the full /_journal/api/v1 surface with PHP shapes, named limiter buckets, optional editor on GET, required backend Bearer on writes, media upload, RSS, and Typesense gate off by default.
+
+Purpose: Phase 16 views and importers can call the frozen PHP contract without Winter.
+Output: routes, API controllers, buckets, search gate, public-list smoke.
+
+
+
+@~/.codex/gsd-core/workflows/execute-plan.md
+@~/.codex/gsd-core/templates/summary.md
+
+
+
+@.planning/phases/15-journal-plugin/15-CONTEXT.md
+@.planning/phases/15-journal-plugin/15-RESEARCH.md
+@.planning/phases/15-journal-plugin/15-PATTERNS.md
+@../sm-journal-plugin/plugin.go
+@../fonoteka.go/plugins/golem15/user/routes.go
+@modules/surf/limiter.go
+@modules/cabana/http.go
+@modules/bouncer/jwt.go
+@modules/beachcomber/searchable.go
+
+
+## Spec-less probe fallback
+
+Visible skip: no REQUIREMENTS.md IDs. D-12/D-14/D-15/D-16/D-17 and RESEARCH §5–§6 are the contract. Do not claim API-09/QA-05.
+
+## Artifacts this phase produces
+
+- Public GETs and backend-authenticated writes under `/_journal/api/v1` as RESEARCH route table.
+- Plugin `surf.BucketProvider` buckets `journal-public-api` and `journal-api`.
+- Plugin-owned backend JWT verify that writes PHP `{error}` strings, not cabana admin envelopes.
+- Media upload JOURNAL-006; RSS XML; beachcomber Gate default off.
+
+
+
+
+ Task 1: Serve anonymous GET /_journal/api/v1/posts as a published list
+ D-14 public prefix /_journal/api/v1 and list envelope become the Phase 16 contract; changing them later breaks views and importers. Locked in CONTEXT — do not re-ask.
+ ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/journal_public_list_smoke_test.go
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-14, D-15, D-16, D-17), .planning/phases/15-journal-plugin/15-RESEARCH.md (§5 route table, routes.php vs API.md, optional editor, PHP error strings, limiters, Pitfall 3 and 4 and 12), .planning/phases/15-journal-plugin/15-PATTERNS.md (routes.go, API controllers, buckets), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/plugin.go (Buckets), modules/surf/limiter.go, modules/pact/capabilities.go (HasRoutes), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/API.md
+ Implement pact.HasRoutes and surf.BucketProvider on Plugin (D-17). Buckets journal-public-api and journal-api, Max 120, Decay one minute. Public key is journal-public| plus surf.ClientIP with TrustedProxies. Write bucket key is journal-api|u: plus backend principal ID when bouncer.User is a backend principal, else ClientIP. 429 body remains surf {"message":"Too Many Attempts."} — do not invent a Journal 429 shape.
+
+Two groups, same prefix /_journal/api/v1 (D-14). Public group middleware throttle:journal-public-api only. Register GET posts, posts/{slug} with Where slug [a-z0-9][a-z0-9\-/]*, categories, tags, rss. Do not attach cabana middleware name backend to the public group (missing token would 401 anonymous callers — pitfall 4).
+
+Implement GET posts index now as the tracer: anonymous callers filter published=true (and published_at not in the future). per_page default 9 max 30 (controller, not API.md 15/50). Envelope {data, meta{current_page,last_page,per_page,total}} with empty arrays as []. Do not apply UNPUBLISHED_TITLE_PREFIX on JSON. Use wire.WriteJSON. Do not wrap errors in the cabana admin envelope (pitfall 3).
+
+Optional editor (PHP isEditor): if Authorization Bearer is present, Lookup *bouncer.Registry is not enough to avoid UnauthorizedWriter — Registry.Middleware(backend) writes cabana unauthenticated. Instead, plugin helper requireBackendPrincipal / optionalBackendPrincipal constructs bouncer.NewBackendJWTGuard with the host admin.jwt.secret, audience backend, and backend_jwt_blacklist, using a PHP-shaped writer only on the write path. On public GET, call Authenticate only when the Bearer header is present; on failure treat as anonymous and do not write 401. If principal has golem15.journal.access_posts, index may include drafts unless ?published=true. author filter is editors only.
+
+Do not add Journal routes to fonoteka.go tide fixtures (D-16).
+
+Smoke TestJournalPublicList: assemble plugin routes, seed one published and one draft post, GET /_journal/api/v1/posts without Authorization returns 200 and only the published row.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalPublicList|TestJournalBuckets)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; either named PASS line is absent.
+
+
+ - GET /_journal/api/v1/posts without Authorization is 200 and omits drafts.
+ - Buckets() contains journal-public-api and journal-api with Max 120.
+ - Public group registration does not use cabana middleware name backend.
+ - Default per_page in index source is 9 and max is 30.
+ - No tide/parity harness file in fonoteka.go is modified.
+
+ Anonymous clients can list published Journal posts at the PHP prefix with the PHP limiter names.
+
+
+
+ Task 2: Slug show, draft 404, and backend-Bearer writes
+ D-15 write auth is backend Bearer only; clients that later expect Apparatus personal tokens need a second guard (deferred todo).
+ ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/controllers/api/auth.go, ../sm-journal-plugin/plugin.go
+ .planning/phases/15-journal-plugin/15-RESEARCH.md (Draft visibility JOURNAL-005, Serialize, error strings, write group, translations object, featured-images), .planning/phases/15-journal-plugin/15-PATTERNS.md (API error analog, write auth, optional editor), modules/cabana/http.go (writeUnauthenticated — do not reuse on this API), modules/bouncer/jwt.go (NewBackendJWTGuard, AudienceBackend), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/PostApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/AccessControlTest.php
+ Register write group: same prefix, throttle:journal-api, required backend principal in-handler (D-15). POST /posts, PUT /posts/{id}, DELETE /posts/{id}, POST /posts/{id}/featured-images, DELETE /posts/{id}/featured-images/{fileId}. Where id [0-9]+. Numeric public show is the slug route with ctype_digit fallback to id (PHP 192-201). Register writes so they do not steal the slug GET.
+
+PHP error strings via wire.WriteJSON: 401 Authentication required; 403 Insufficient permissions or You do not have permission to publish posts; 404 Post not found with no data key; 422 Validation failed plus errors map. Do not emit Apparatus-token copy from stale API.md.
+
+Writes: verify backend JWT HS256 audience backend and blacklist backend_jwt_blacklist. Reject frontend user JWTs (wrong audience). Do not implement Apparatus personal-token parsing. Store/update assign field-by-field (title, slug, content, excerpt, published, published_at, is_pinned, sources, metadata, category/tag ids, translations.* via translate SetTranslated). Never lagoon.Fill the whole body onto Post. Stamp user_id from principal on create. access_posts required; canEdit on update/delete; access_publish for publish flags. Regenerate content_html with FormatHTML.
+
+Show: unpublished or published_at in the future is unpublished. 404 no data unless caller is owner (user_id) or holds access_other_posts (JOURNAL-005). Anonymous always 404 for drafts (never 403 that confirms existence). Include previous_post, next_post, related_posts on show. Do not prefix titles with the unpublished lock emoji.
+
+GET categories and GET tags list public serialized rows (D-14). Categories JSON is {data} of id, name, slug, description, parent_id, nest_depth, post_count, optional children; omit zero-published-post categories unless include_empty=1. Tags JSON is {data} of id, name, slug, post_count ordered by name. Empty data is [].
+
+Keep featured-image upload/delete behind the write group and access_posts (D-15). Write TestJournalPublicCategories, TestJournalPublicTags, and TestJournalFeaturedImageUnauthenticated in posts_test.go so skipping those handlers fails this task: anonymous GET /_journal/api/v1/categories and /tags return 200 with the PHP list keys; POST /posts/{id}/featured-images and DELETE /posts/{id}/featured-images/{fileId} without Bearer return 401 with JSON error string Authentication required; a backend Bearer that cannot edit the post returns 403 with You do not have permission to edit this post.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalWriteUnauthenticated|TestJournal005DraftShow|TestJournalPublicCategories|TestJournalPublicTags|TestJournalFeaturedImageUnauthenticated)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournalWriteUnauthenticated, TestJournal005DraftShow, TestJournalPublicCategories, TestJournalPublicTags, TestJournalFeaturedImageUnauthenticated).
+
+
+ - POST /_journal/api/v1/posts without Bearer is 401 and the JSON error value is the string Authentication required.
+ - That 401 body is not the cabana admin unauthenticated envelope.
+ - Draft show to a stranger is 404 and the object has no data key.
+ - Owner or access_other_posts can GET a draft by slug or numeric id.
+ - Publish without access_publish is 403 with the PHP publish permission string.
+ - Frontend-audience JWTs do not authorize writes.
+ - Anonymous GET /_journal/api/v1/categories is 200 with PHP {data} rows of id, name, slug, description, parent_id, nest_depth, post_count.
+ - Anonymous GET /_journal/api/v1/tags is 200 with PHP {data} rows of id, name, slug, post_count.
+ - POST and DELETE featured-images without Bearer are 401 with JSON error string Authentication required.
+ - Featured-image writes with a backend Bearer that cannot edit the post are 403 with You do not have permission to edit this post.
+
+ Editors can create and edit posts with backend Bearer, and drafts do not leak to anonymous clients.
+
+
+
+ Task 3: Media upload, RSS, Typesense gate, and host route assertion
+ ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/search.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/README.md, ../sm-grzybyfunkcjonalne-app/boot_test.go
+ .planning/phases/15-journal-plugin/15-CONTEXT.md (D-12), .planning/phases/15-journal-plugin/15-RESEARCH.md (Media JOURNAL-006, Search D-12, RSS, Pitfall 6 and 11), .planning/phases/15-journal-plugin/15-PATTERNS.md (media.go, Searchable analog), modules/beachcomber/searchable.go, modules/beachcomber/example_test.go (installGate), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/api/MediaApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (shouldBeSearchable, searchableAs), ../sm-grzybyfunkcjonalne-app/boot_test.go
+ POST /_journal/api/v1/media/upload: require backend principal plus access_posts (JOURNAL-006). Folder regex ^[A-Za-z0-9_\-/]*$; force under journal/; strip a leading journal segment; reject .. . Image MIME jpg/jpeg/png/gif/webp; max 10240 KB in the handler even if host upload_bytes is larger. 201 {data:{url,path}}. Use gocloud blob already in the host graph.
+
+GET rss: stdlib encoding/xml, PHP PostApiController::rss contract (D-14). Honor rss_* settings; empty/disabled still a well-formed feed. Write TestJournalRSS in posts_test.go: anonymous GET /_journal/api/v1/rss is 200, Content-Type application/rss+xml, body parses as RSS 2.0 with a channel, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted, rss_include_content false omits content:encoded, rss_enabled false still returns well-formed XML. Route registration alone does not satisfy this task.
+
+Post SearchableAs golem15_journal_posts. Set beachcomber.Gate from golem15_journal_settings.search_use_typesense for ID=1; read errors count as off (D-12). ShouldBeSearchable false when unpublished or gate off. List search: if query length at least 3 and gate on, SearchPage then SQL re-gate; on engine error log and ILIKE fallback. Fresh install never dials Typesense. Do not enable the setting by default.
+
+Extend host TestBootUserTranslateJournal to assert assembled routes include GET /_journal/api/v1/posts and POST /_journal/api/v1/posts. Document the public prefix in the plugin README with blog examples.
+
+Index search wiring may be a method on Post plus search.go Gate install at Boot.
+
+ go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournal006MediaUpload|TestSearchGateOff|TestJournalRSS)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'
+ Non-zero exit; output contains "--- FAIL", "--- SKIP", or "no tests to run"; any of the named PASS lines is absent (TestJournal006MediaUpload, TestSearchGateOff, TestJournalRSS, TestBootUserTranslateJournal).
+
+
+ - Media upload without access_posts is 403; with permission is 201 and path is under journal/.
+ - Folder values containing .. are rejected.
+ - TestSearchGateOff saves a published post with default settings and records zero outbound search HTTP.
+ - Host boot test sees GET and POST /_journal/api/v1/posts.
+ - Anonymous GET /_journal/api/v1/rss is 200, Content-Type is application/rss+xml, body is well-formed RSS 2.0, channel title follows rss_title, item count follows rss_posts_per_feed, unpublished posts are omitted.
+
+ Media, RSS, and the off-by-default search gate complete the D-14/D-12 HTTP surface.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Anonymous HTTP → public GET | Untrusted clients must not see drafts |
+| Bearer header → write handlers | Only backend-audience JWTs may mutate posts |
+| Multipart folder/path → blob | Traversal must not write outside journal/ |
+| Post save → Typesense | Gate off must produce zero network |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-15-01 | Spoofing | POST /_journal/api/v1/posts | high | mitigate | In-handler backend JWT; 401 Authentication required; reject frontend audience |
+| T-15-02 | Information Disclosure | GET posts/{slug} drafts | high | mitigate | 404 without data unless owner or access_other_posts (JOURNAL-005) |
+| T-15-03 | Tampering | POST /media/upload | high | mitigate | access_posts, folder regex, forced journal/ prefix, MIME/size (JOURNAL-006) |
+| T-15-10 | Spoofing | write API tokens | high | mitigate | Backend aud only; no frontend personal token; no Apparatus personal tokens |
+| T-15-11 | Information Disclosure | Typesense sync | high | mitigate | Gate default off; unpublished not searchable |
+| T-15-12 | Denial of Service | X-Forwarded-For | medium | mitigate | surf.TrustedProxies + ClientIP on both buckets |
+| T-15-13 | Tampering | error envelope | high | mitigate | PHP {error} string writer; do not call cabana admin error helper |
+| T-15-SC | Tampering | package installs | high | mitigate | No new packages |
+
+ASVS L1: all high threats mitigated; Plan 04 removal tests. block_on high.
+
+
+
+Run all three task commands plus go -C ../sm-journal-plugin test ./... -short -count=1.
+
+
+
+- Anonymous list/show/categories/tags/rss work.
+- Writes require backend Bearer with PHP error strings.
+- Drafts 404 to strangers; media stays under journal/.
+- Typesense is never contacted on a fresh install.
+- Journal is not in the Płytarium tide harness.
+
+
+
diff --git a/.planning/phases/15-journal-plugin/15-04-PLAN.md b/.planning/phases/15-journal-plugin/15-04-PLAN.md
new file mode 100644
index 0000000..56f6a18
--- /dev/null
+++ b/.planning/phases/15-journal-plugin/15-04-PLAN.md
@@ -0,0 +1,287 @@
+---
+phase: 15-journal-plugin
+plan: 04
+type: execute
+wave: 4
+depends_on: ["15-03"]
+files_modified:
+ - ../sm-journal-plugin/updates/postgres_test.go
+ - ../sm-journal-plugin/updates/migrations_test.go
+ - ../sm-journal-plugin/models/fillable_test.go
+ - ../sm-journal-plugin/models/translatable_test.go
+ - ../sm-journal-plugin/classes/format_html_test.go
+ - ../sm-journal-plugin/admin_harness_test.go
+ - ../sm-journal-plugin/controllers/api/posts_test.go
+ - ../sm-journal-plugin/controllers/api/media_test.go
+ - ../sm-journal-plugin/search_test.go
+ - ../sm-journal-plugin/integration_test.go
+ - ../sm-grzybyfunkcjonalne-app/boot_test.go
+ - scripts/check-phase15.sh
+ - .planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md
+ - .planning/phases/15-journal-plugin/15-VALIDATION.md
+autonomous: true
+requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22, D-23]
+estimate:
+ tokens: 90000
+ raw_tokens: 90000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-05: every RESEARCH §8 PHPUnit row has a named Go test (JOURNAL-001/002 fillable, JOURNAL-005 draft 404, JOURNAL-006 media, FormatHTML substitutes JOURNAL-003/004 templates, redactor_id not fillable)."
+ - "Real Postgres migrates all golem15_journal_* tables plus author_slug, rolls back, and remigrates."
+ - "Anonymous list hides drafts; GET categories and tags return 200 PHP {data} lists; GET rss is well-formed RSS 2.0 honoring rss_*; write without Bearer is 401 Authentication required including featured-images POST/DELETE; Typesense gate off records zero HTTP."
+ - "Host Activate still returns user+translate+journal; CORS includes _journal/api/*; plugin README stays application-neutral."
+ - "scripts/check-phase15.sh --all is fail-closed; 15-SECURITY-REVIEW.md closes every high T-15-* threat."
+ artifacts:
+ - path: "../sm-journal-plugin/integration_test.go"
+ provides: "end-to-end migrate, admin create, public GET, Bearer write, draft 404"
+ contains: "TestJournalEndToEnd"
+ - path: "../sm-journal-plugin/models/fillable_test.go"
+ provides: "JOURNAL-001/002"
+ contains: "TestFillable"
+ - path: "../sm-journal-plugin/controllers/api/posts_test.go"
+ provides: "JOURNAL-005, 401 PHP shape, categories/tags lists, RSS XML, featured-image auth"
+ contains: "TestJournal005DraftShow"
+ - path: "scripts/check-phase15.sh"
+ provides: "fail-closed phase gate"
+ contains: "sm-journal-plugin"
+ - path: ".planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md"
+ provides: "ASVS L1 threat evidence"
+ contains: "T-15-01"
+ key_links:
+ - from: "../sm-journal-plugin/integration_test.go"
+ to: "../sm-journal-plugin/routes.go"
+ via: "assembled public GET and Bearer POST through production handlers"
+ pattern: "TestJournalEndToEnd"
+ - from: "scripts/check-phase15.sh"
+ to: "../sm-journal-plugin/updates/migrations_test.go"
+ via: "full plugin suite with Docker/Postgres, not -short as final evidence"
+ pattern: "go -C"
+ - from: ".planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md"
+ to: "../sm-journal-plugin/controllers/api/posts_test.go"
+ via: "each high threat cites an executed TestName"
+ pattern: "T-15-"
+ prohibitions:
+ - requirement_id: D-07
+ category: safety
+ statement: "Gate fails if the PHP journal tree at SHA 02110eb has a git diff"
+ status: resolved
+ verification: test
+ - requirement_id: D-16
+ category: architecture
+ statement: "Gate fails if fonoteka.go parity/tide files newly mention /_journal/api/v1"
+ status: resolved
+ verification: test
+ - requirement_id: D-12
+ category: safety
+ statement: "TestSearchGateOff must not skip and must observe zero search HTTP"
+ status: resolved
+ verification: test
+---
+
+## Phase Goal
+
+**As a** application developer, **I want to** mount `sm-journal-plugin` in a host the same way `sm-user-plugin` mounts, **so that** a blog can run on SummerCMS without the PHP plugin.
+
+This plan's slice: the last plan of the phase — full unit/integration coverage, PHPUnit map, phase gate, and security review.
+
+
+Finish Phase 15 with the dedicated test plan: PHPUnit behavioral map, migration rollback, HTTP/security cases, fail-closed gate, and ASVS L1 review.
+
+Purpose: every locked D-ID and high threat fails closed when broken.
+Output: test matrix, scripts/check-phase15.sh, 15-SECURITY-REVIEW.md, validated 15-VALIDATION.md.
+
+
+
+@~/.codex/gsd-core/workflows/execute-plan.md
+@~/.codex/gsd-core/templates/summary.md
+
+
+
+@.planning/phases/15-journal-plugin/15-VALIDATION.md
+@.planning/phases/15-journal-plugin/15-RESEARCH.md
+@.planning/phases/15-journal-plugin/15-PATTERNS.md
+@../sm-translate-plugin/updates/postgres_test.go
+@../sm-translate-plugin/admin_harness_test.go
+@scripts/check-phase14.2.1.sh
+
+
+## Spec-less probe fallback
+
+Visible skip: no REQUIREMENTS.md IDs and no phase SPEC Edge Coverage/Prohibitions to lift. Tests map to D-01..D-23, RESEARCH §8, and VALIDATION rows. Do not generate probe predicates. Do not claim API-09 or QA-05.
+
+## API coverage
+
+No external API integration: this phase ports a compiled plugin's own `/_journal/api/v1` surface and an optional beachcomber Gate that stays off; it does not integrate a third-party SaaS SDK. Do not fabricate a capability matrix.
+
+## Artifacts this phase produces
+
+- `TestJournalEndToEnd` spanning migrate, Activate, admin mlmarkdown save, anonymous list, Bearer write, JOURNAL-005, media 403.
+- Real-Postgres up/down for all seven journal migrations.
+- `scripts/check-phase15.sh` with plugin/host/PHP-pin/docs-neutral/security stages using `go -C ../sm-journal-plugin` and `go -C ../sm-grzybyfunkcjonalne-app`.
+- `15-SECURITY-REVIEW.md` and completed `15-VALIDATION.md`.
+
+## Multi-source coverage audit
+
+| SOURCE | ID | Feature/Requirement | Plan | Status | Notes |
+|---|---|---|---|---|---|
+| GOAL | — | Port Golem15.Journal to sm-journal-plugin and mount in a host | 01-04 | COVERED | Schema, admin, API, tests |
+| REQ | — | No mapped requirement IDs (TBD) | — | COVERED | Visible spec-less fallback; D-IDs used; API-09/QA-05 are Phase 20 |
+| CONTEXT | D-01..D-04 | Frozen PHP pin, tables, YAML/API binding | 01-04 | COVERED | SHA asserted; PHP unchanged |
+| CONTEXT | D-05 | PHPUnit map | 04 | COVERED | RESEARCH §8 |
+| CONTEXT | D-06 | en+pl only | 01,04 | COVERED | Lang files + gate |
+| CONTEXT | D-07 | No PHP edits | 01,04 | COVERED | git diff empty |
+| CONTEXT | D-08 | Nav SVG | 02,04 | COVERED | Embedded bytes |
+| CONTEXT | D-09 | Translate is a prior phase; Journal Requires it | 01,04 | COVERED | No Translate port inside Journal |
+| CONTEXT | D-10 | Translatable attributes | 01-04 | COVERED | MorphName PHP strings |
+| CONTEXT | D-11 | cabana markdown | 02,04 | COVERED | No-op; mlmarkdown only |
+| CONTEXT | D-12 | Typesense off by default | 03,04 | COVERED | Gate + TestSearchGateOff |
+| CONTEXT | D-13 | CSV CLI + toolbar | 02,04 | COVERED | TestJournalCommands |
+| CONTEXT | D-14 | Full /_journal/api/v1 | 03,04 | COVERED | routes.php wins |
+| CONTEXT | D-15 | Backend Bearer writes | 03,04 | COVERED | Not frontend tokens |
+| CONTEXT | D-16 | Not tide | 03,04 | COVERED | Gate forbids harness add |
+| CONTEXT | D-17 | Limiters + CORS | 01,03,04 | COVERED | Buckets + http.yaml |
+| CONTEXT | D-18..D-23 | Proof host and remotes | 01,04 | COVERED | Three-plugin boot |
+| RESEARCH | — | Squash golem15_journal_*; no rainlab-era names | 01,04 | COVERED | Migration tests |
+| RESEARCH | — | YAML rewrite; FilterScopes | 02,04 | COVERED | Form compile tests |
+| RESEARCH | — | FormatHTML plugin-local | 02,04 | COVERED | XSS substitute tests |
+| RESEARCH | — | PHP {error} JSON; per_page 9/30; slug show | 03,04 | COVERED | API tests |
+| RESEARCH | — | Sibling replace ../summercms.go | 01,04 | COVERED | Isolated go test |
+
+Excluded (deferred / other phases): Phase 16 HTML/views/components; Winter.Pages menu types; dashboard widget; Apparatus personal tokens; 19 extra locales; WYSIWYG/redactor; editing wn-journal-plugin; tide 154-route harness; sitemap.
+
+
+
+
+ Task 1: Prove migrate → admin save → anonymous list → Bearer write → draft 404 end to end
+ ../sm-journal-plugin/updates/postgres_test.go, ../sm-journal-plugin/admin_harness_test.go, ../sm-journal-plugin/integration_test.go, ../sm-grzybyfunkcjonalne-app/boot_test.go
+ ../sm-translate-plugin/updates/postgres_test.go, ../sm-translate-plugin/admin_harness_test.go, ../sm-translate-plugin/integration_test.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/api/posts.go, .planning/phases/15-journal-plugin/15-VALIDATION.md, .planning/phases/15-journal-plugin/15-RESEARCH.md (§8 PHPUnit map)
+ Copy the translate/user fail-closed TestMain: testcontainers Postgres, dedicated database, Docker unavailability fails full runs; only explicit -short may skip.
+
+TestJournalEndToEnd (D-05, D-14, D-15, D-19): migrate user, translate, and journal in Requires order; party.Activate those three plus a process-local test fixture only if needed (no production fixture plugin); cabana.Activate; surf.Assemble. Mint a backend principal with golem15.journal.access_posts plus access_publish, and a second principal without access_other_posts.
+
+Create a published post and a draft via admin or model helpers using mlmarkdown maps for en/pl. Assert English on host columns and Polish in golem15_translate_attributes under MorphName Golem15\Journal\Models\Post.
+
+GET /_journal/api/v1/posts with no Authorization returns 200 and only the published post (D-14). POST /posts without Bearer is 401 Authentication required (D-15, T-15-01). POST with backend Bearer creates a row. GET draft slug as anonymous is 404 with no data key (JOURNAL-005, T-15-02). Owner or access_other_posts sees 200.
+
+Host TestBootUserTranslateJournal still activates three plugins, sees Journal controller IDs, CORS path, and /_journal/api/v1 GET+POST. It must not duplicate the full fixture matrix.
+
+Do not import sm-user-plugin from journal production code; host tests may join users if present. redactor_id column exists and is not in Fillable; set only via explicit assign (PostRedactor analog, D-05).
+
+ go -C ../sm-journal-plugin test ./... -count=1 -v -run '^(TestJournalEndToEnd)$' && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'
+ Non-zero exit; either run prints "--- FAIL", "--- SKIP", "no tests to run", or container startup treated as skip; lacks its named PASS line.
+
+
+ - Integration uses real Postgres and production gormigrate/party/surf/cabana paths.
+ - Anonymous list hides the draft; draft show 404 has no data key.
+ - Bearer write succeeds for a permitted backend principal and 401s without Authorization using the PHP string error.
+ - Polish title is in translate attributes keyed by the PHP Post morph string.
+ - Host boot still lists exactly the three production plugins.
+
+ The Phase 15 user-visible path is proven on real Postgres before the horizontal test matrix.
+
+
+
+ Task 2: Complete PHPUnit map, migrations, YAML, FormatHTML, and search-gate tests
+ ../sm-journal-plugin/updates/migrations_test.go, ../sm-journal-plugin/models/fillable_test.go, ../sm-journal-plugin/models/translatable_test.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/controllers/api/posts_test.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/search_test.go, ../sm-journal-plugin/admin_harness_test.go
+ .planning/phases/15-journal-plugin/15-RESEARCH.md (§8 PHPUnit map, JOURNAL-001..006), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/AccessControlTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/MassAssignmentTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/security/XssTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/tests/unit/models/PostRedactorTest.php, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/controllers/api/media.go, modules/cabana/markdown_test.go
+ Complete D-05 without porting Phase 16 Twig templates.
+
+Migrations (D-01, D-04): TestJournalTables and rollback/remigrate assert every final table/column/index, unique slugs, JSONB metadata/sources, settings defaults including search_use_typesense false, author_slug on backend_users, and absence of rainlab-era journal table names.
+
+Fillable (JOURNAL-001/002, T-15-04): Tag allow-list name/slug/description only; Category excludes nest_*; extra JSON keys dropped; Post API assigns never persist redactor_id or user_id from the body.
+
+Translatable (D-10): Post/Category MorphName PHP strings; slug is indexed; Tag has no Translatable.
+
+FormatHTML (JOURNAL-003/004 substitute): reject script, iframe, event handlers, javascript/vbscript/data schemes; footnotes/tables from goldmark extensions still pass the reject gate. Do not port .htm files.
+
+API: per_page 9/30; slug show; numeric fallback; previous_post/next_post/related_posts present on show; unpublished lock prefix absent from JSON; editor Bearer on GET sees drafts; invalid frontend-audience token on POST is 401; publish without access_publish 403. Named TestJournalPublicCategories and TestJournalPublicTags: anonymous GET /_journal/api/v1/categories and /tags return 200 PHP {data} list shapes (categories honor include_empty; tags ordered by name). Named TestJournalRSS: GET /rss is well-formed RSS 2.0 XML honoring rss_title, rss_posts_per_feed, rss_include_content, and rss_enabled. Named TestJournalFeaturedImageUnauthenticated: featured-image POST/DELETE without Bearer is 401 Authentication required; without canEdit is 403 You do not have permission to edit this post.
+
+Media (JOURNAL-006, T-15-03): 403 without access_posts; 201 with permission; folder .. rejected; stored path under journal/.
+
+Search (D-12, T-15-11): TestSearchGateOff zero HTTP; unpublished ShouldBeSearchable false even if someone flipped the setting in-memory.
+
+Admin: 403 without access_posts; YAML compile TestPostsFormCompiles; FilterScopes without illegal filter keys; commands registered; SVG embed present.
+
+Limiter names and CORS: TestJournalBuckets; host or plugin test reading ../sm-grzybyfunkcjonalne-app/config/http.yaml requires _journal/api/* (D-17). Isolated plugin tests that cannot see the host file skip only that assertion, not the bucket test.
+
+D-11: assert content field type mlmarkdown in compiled schema; assert modules/cabana/form_schema.go already lists markdown/mltext/mlmarkdown (no-op this phase). D-16: no new tide fixtures.
+
+ go -C ../sm-journal-plugin test ./... -count=1 -v -race && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -race
+ Non-zero exit; Go race detector reports a race; any package reports FAIL; integration tests unexpectedly SKIP in the plugin run; output lacks PASS lines for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, and TestJournalFeaturedImageUnauthenticated.
+
+
+ - Named tests exist for JOURNAL-001, JOURNAL-002, JOURNAL-005, JOURNAL-006, FormatHTML unsafe tags, redactor_id not fillable, TestSearchGateOff, TestJournalCommands, TestPostsFormCompiles, TestJournalBuckets, TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, TestJournalFeaturedImageUnauthenticated.
+ - Migration rollback and remigrate pass on real Postgres.
+ - High threats T-15-01, T-15-02, T-15-03, T-15-04, T-15-07, T-15-08, T-15-09, T-15-10, T-15-11, T-15-13 have fail-when-broken tests.
+ - No test requires Pages menu types, dashboard widgets, extra locales, or PHP tree writes.
+
+ Every D-05 PHPUnit row and every in-scope high threat has named Go evidence.
+
+
+
+ Task 3: Phase gate, security review, and validation sign-off
+ scripts/check-phase15.sh, .planning/phases/15-journal-plugin/15-SECURITY-REVIEW.md, .planning/phases/15-journal-plugin/15-VALIDATION.md
+ scripts/check-phase14.2.1.sh, .planning/phases/15-journal-plugin/15-VALIDATION.md, .planning/phases/15-journal-plugin/15-RESEARCH.md (Security Domain), .planning/phases/15-journal-plugin/15-01-PLAN.md, .planning/phases/15-journal-plugin/15-02-PLAN.md, .planning/phases/15-journal-plugin/15-03-PLAN.md
+ Create scripts/check-phase15.sh modeled on check-phase14.2.1.sh. Stages: PHP SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88 and empty git diff on the PHP journal tree (D-01, D-02, D-03, D-07); plugin module/layout/replace ../summercms.go (D-22, D-23, pitfall 9); plugin go vet, full tests, race; host go vet/test/build including TestBootUserTranslateJournal (D-18..D-21); CORS _journal/api/*; plugin README forbidden-name scan (the application / blog only); no rainlab-era table names; no cabana field_markdown.go diff from this phase (D-11 no-op); no tide harness add (D-16); security-review file present. Use go -C ../sm-journal-plugin and go -C ../sm-grzybyfunkcjonalne-app. Full mode requires Postgres; Docker missing is failure; -short is not final evidence. Require named PASS lines for TestJournalEndToEnd, TestJournal005DraftShow, TestJournal006MediaUpload, TestFillable, TestSearchGateOff, TestJournalWriteUnauthenticated, TestBootUserTranslateJournal, TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, TestJournalFeaturedImageUnauthenticated. End with Phase 15 gate passed.
+
+Run the security-review lane over local Phase 15 changes (CONTEXT discretion: after implementation, not a code-writing plan). If a typed security-review subagent is unavailable, self-perform as 14.2.1-04 did and disclose that in 15-SECURITY-REVIEW.md frontmatter. Produce 15-SECURITY-REVIEW.md at ASVS L1, block_on high. Preserve unique threat IDs T-15-01 through T-15-15 plus T-15-SC (reserved, never colliding). For every high threat cite source control and executed TestName. Review draft enumeration, media traversal, fillable, stored XSS in content_html, cross-user edit, publish permission, frontend token on writes, Typesense leak, rate-limit XFF, envelope mixup, submodule provenance.
+
+Record the API-coverage declaration in the review: no external SaaS SDK this phase.
+
+Update 15-VALIDATION.md frontmatter to validated / nyquist_compliant / wave_0_complete only after mapped commands pass. Replace pending rows with exact test names and threat refs. Keep the manual SPA UAT row (admin login, Journal nav, mlmarkdown post) as human-check, not a silent pass.
+
+Do not commit unless the user asks; this planner run also does not commit.
+
+ bash scripts/check-phase15.sh --all
+ Non-zero exit; any stage absent or skipped; output contains FAIL, a Go race, no-tests-to-run, unexpected SKIP, unmitigated high threat, PHP tree dirty; lacks PASS evidence for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, or TestJournalFeaturedImageUnauthenticated; or lacks the final Phase 15 gate passed line.
+
+
+ - Gate uses go -C ../sm-journal-plugin and go -C ../sm-grzybyfunkcjonalne-app.
+ - PHP pin SHA matches 02110eb1c0c3861370b0b9b47b209a0702ac5d88 and the PHP tree has no diff.
+ - 15-SECURITY-REVIEW.md lists each T-15-NN once, keeps T-15-SC, and blocks on high findings.
+ - VALIDATION rows name existing tests; frontmatter is validated only after green execution.
+ - Gate confirms no PHP edits, no extra locales, no Pages/dashboard, no tide add, no Typesense contact in TestSearchGateOff, no consuming-application name in the plugin README.
+ - Gate requires named PASS for TestJournalPublicCategories, TestJournalPublicTags, TestJournalRSS, and TestJournalFeaturedImageUnauthenticated.
+
+ The two-repository phase gate is green, high threats are mitigated with executed evidence, and validation is signed off.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Test/gate → production claims | A no-op filter, skipped container, or dirty PHP tree must not pass |
+| Security review → phase completion | High findings block completion |
+| Public HTTP → draft rows | JOURNAL-005 regressions must fail the gate |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-15-14 | Repudiation | phase gate | high | mitigate | Require named PASS lines and final marker; reject no-tests and unexpected skips |
+| T-15-15 | Information Disclosure | unpublished title prefix on API | medium | mitigate | Assert JSON titles omit the unpublished lock prefix |
+| T-15-SC | Tampering | package installs | high | mitigate | Gate confirms no new undecided require in plugin go.mod |
+
+ASVS L1: block_on high. T-15-01..T-15-13 originate in plans 01–03 and must appear in 15-SECURITY-REVIEW.md with executed tests, not as duplicate rows here.
+
+
+
+Run TestJournalEndToEnd, the race suites, then bash scripts/check-phase15.sh --all.
+
+
+
+- PHPUnit map D-05 is covered by named Go tests.
+- Plugin and host vet/test/race are green.
+- Phase 15 gate passed.
+- High T-15 threats are mitigated with evidence.
+- Deferred Phase 16/Pages/dashboard/Apparatus-token/extra-locale/PHP-edit items remain absent.
+
+
+
diff --git a/.planning/phases/15-journal-plugin/15-PATTERNS.md b/.planning/phases/15-journal-plugin/15-PATTERNS.md
new file mode 100644
index 0000000..cfa731c
--- /dev/null
+++ b/.planning/phases/15-journal-plugin/15-PATTERNS.md
@@ -0,0 +1,662 @@
+# Phase 15: Journal plugin - Pattern Map
+
+**Mapped:** 2026-10-06
+**Files analyzed:** 54 new or modified files (plugin + host + tests); 0 framework field-type files
+**Analogs found:** 52 / 54
+
+Path roots:
+
+- `FW/` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` (working directory; relative paths below are from here unless prefixed).
+- `TR/` = `../sm-translate-plugin/` — closest compiled-plugin analog (sibling checkout, `Requires` empty, `Translatable`, host already mounts it).
+- `USR/` = `../fonoteka.go/plugins/golem15/user/` — the `sm-user-plugin` submodule. Closest analog for `HasRoutes`, `Buckets`, CLI commands, YAML with filters/relations/fileupload, `ListExtendQuery`, CORS test. `/media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/` does **not** exist; this path does.
+- `APP/` = `../sm-grzybyfunkcjonalne-app/` — proof host from 14.2.1 (user + translate only today).
+- `PHP/` = `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` at SHA `02110eb1c0c3861370b0b9b47b209a0702ac5d88` (verified `git rev-parse HEAD` this session). Read-only contract. Do not edit (D-07).
+- `JR/` = `../sm-journal-plugin/` — **does not exist yet** (D-23). Plan 01 clones `git@git.golem15.com:golem15/sm-journal-plugin.git` there.
+
+All analog paths below are git-tracked (`git ls-files` in `summercms.go`, inside `sm-translate-plugin`, inside the user submodule, inside `sm-grzybyfunkcjonalne-app`, and inside the PHP journal plugin). No gitignored mirror is named. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: this phase does **not** regenerate them (D-11 already shipped).
+
+**D-11 no-op:** `type: markdown` / `mltext` / `mlmarkdown` already live in `FW/modules/cabana/form_schema.go` lines 24-29 and `docs/backend/forms.md` 324-346. Do not add a second YAML type. Do not change `cabana.RenderMarkdown`. Journal post `content` is `mlmarkdown`. Plugin `FormatHTML` owns footnotes/tables/attributes + the same rejectUnsafe gate.
+
+**Replace paths (locked RESEARCH pitfall 9):** sibling plugin `go.mod` uses `replace git.golem15.com/golem15/summercms => ../summercms.go` (`TR/go.mod` line 122). Host uses `replace …sm-journal-plugin => ./plugins/golem15/journal`. Do **not** copy `USR/go.mod`'s `../../../../summercms.go` into the sibling checkout.
+
+**Tables (locked):** squash to `golem15_journal_*`. Do not emit `rainlab_journal_*`. Settings is a dedicated singleton table ID=1, not PHP `system_settings`.
+
+Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **15-01** clone plugin + squashed schema + models + Translatable + host submodule/`summer.yaml`/`go.work`/`http.yaml` CORS + boot_test 3 plugins; **15-02** admin controllers, adapted YAML, settings, permissions, nav SVG, FormatHTML, import/export CLI + toolbar; **15-03** `/_journal/api/v1`, buckets, optional editor on GET, backend Bearer writes, media upload, Typesense gate; **15-04** unit/integration tests last.
+
+## File Classification
+
+### New plugin (`JR/` = `sm-journal-plugin`)
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|-------------------|------|-----------|----------------|---------------|
+| `JR/go.mod` | config | — | `TR/go.mod` (sibling replace `../summercms.go`) | exact |
+| `JR/plugin.go` | provider | request-response | `TR/plugin.go` + `USR/plugin.go` (`HasRoutes`/`Buckets`/`HasCommands`) | exact |
+| `JR/README.md` | docs | — | `TR/README.md` (neutral names; never name the host) | exact |
+| `JR/admin.go` | provider | — | `TR/admin.go` + `USR/admin.go` (file-by-file embed) | exact |
+| `JR/admin_permissions.go` | config | — | `USR/admin_permissions.go` + `PHP/Plugin.php` 55-90 | exact |
+| `JR/admin_navigation.go` | config | — | `USR/admin_navigation.go` + `PHP/Plugin.php` 96-134; icon alias `FW/admin/src/app/icons.ts` 273 | exact |
+| `JR/lang/en/lang.yaml`, `JR/lang/pl/lang.yaml` | config | transform | `TR/lang/{en,pl}/lang.yaml` + `PHP/lang/en/lang.php` keys `plugin.*` / `journal.*` / `post.*` | exact |
+| `JR/assets/images/journal-icon.svg` | config | file-I/O | `PHP/assets/images/journal-icon.svg` (embed bytes; D-08) | exact |
+| `JR/models/registry.go` | utility | — | `TR/models/registry.go` | exact |
+| `JR/models/post.go` | model | CRUD | `USR/models/user.go` (Fillable/MorphName/AttachRelations/FilterScope) + `PHP/models/Post.php` (translatable, canEdit, formatHtml, Searchable) | exact |
+| `JR/models/category.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Category.php` fillable 45-51 | exact |
+| `JR/models/tag.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Tag.php` fillable 27-31 | exact |
+| `JR/models/settings.go` | model | CRUD | `FW/modules/cabana/example_controller_test.go` `BlogSettings` 58-72 | exact |
+| `JR/updates/registry.go` | utility | — | `TR/updates/registry.go` | exact |
+| `JR/updates/202610060001_create_golem15_journal_posts.go` | migration | CRUD | `TR/updates/202610060001_create_golem15_translate_locales.go` | exact |
+| `JR/updates/202610060002_create_golem15_journal_categories.go` | migration | CRUD | same CREATE pattern | exact |
+| `JR/updates/202610060003_create_golem15_journal_tags.go` | migration | CRUD | same CREATE pattern | exact |
+| `JR/updates/202610060004_create_golem15_journal_posts_categories.go` | migration | CRUD | same CREATE pattern (pivot) | exact |
+| `JR/updates/202610060005_create_golem15_journal_posts_tags.go` | migration | CRUD | same CREATE pattern (pivot) | exact |
+| `JR/updates/202610060006_create_golem15_journal_settings.go` | migration | CRUD | same CREATE pattern (singleton ID=1) | exact |
+| `JR/updates/202610060007_add_author_slug_to_backend_users.go` | migration | CRUD | `USR/updates/202609220005_extend_users.go` (ALTER); use `IF NOT EXISTS` | role-match |
+| `JR/classes/format_html.go` | service | transform | `FW/modules/cabana/field_markdown.go` rejectUnsafe + `PHP/models/Post.php` `formatHtml` 199-225 | partial |
+| `JR/controllers/admin_registry.go` | config | — | `TR/controllers/admin_registry.go` + `USR/controllers/admin_registry.go` | exact |
+| `JR/controllers/posts.go` | controller | CRUD | `USR/controllers/users_admin_controller.go` (ListExtendQuery, FormBeforeCreate, permissions) | exact |
+| `JR/controllers/categories.go` | controller | CRUD | `TR/controllers/locales.go` | exact |
+| `JR/controllers/tags.go` | controller | CRUD | `TR/controllers/locales.go` | exact |
+| `JR/controllers/posts/{config_list,config_form,config_filter}.yaml` | config | file-I/O | `USR/controllers/users/*.yaml` + `PHP/controllers/posts/*` (adapt, do not byte-copy) | exact |
+| `JR/controllers/categories/{config_list,config_form}.yaml` | config | file-I/O | `TR/controllers/locales/*.yaml` + `USR/controllers/usergroups/*.yaml` | exact |
+| `JR/controllers/tags/{config_list,config_form}.yaml` | config | file-I/O | same | exact |
+| `JR/models/post/{fields,columns}.yaml` | config | file-I/O | `USR/models/user/fields.yaml` (relation/fileupload) + `FW/docs/backend/forms.md` 332-346 (`mltext`/`mlmarkdown`) | exact |
+| `JR/models/category/{fields,columns}.yaml` | config | file-I/O | `TR/models/locale/fields.yaml` + `PHP/models/category/*` | exact |
+| `JR/models/tag/{fields,columns}.yaml` | config | file-I/O | same | exact |
+| `JR/models/settings/fields.yaml` | config | file-I/O | `PHP/models/settings/fields.yaml` minus `trigger`/`placeholder` | exact |
+| `JR/routes.go` | route | request-response | `USR/routes.go` + `PHP/routes.php` | exact |
+| `JR/controllers/api/posts.go` | controller | request-response | `USR/controllers/api_controller.go` (flat `{error}` JSON) + `PHP/controllers/api/PostApiController.php` | exact |
+| `JR/controllers/api/media.go` | controller | file-I/O | `PHP/controllers/api/MediaApiController.php` + `USR` avatar upload (blob, mime, size) | exact |
+| `JR/console/export_posts.go`, `JR/console/import_posts.go` | utility | batch | `USR/console/require_password_change.go` + `PHP/console/ExportPosts.php` | exact |
+| `JR/search.go` (Gate on Post, or methods on `post.go`) | service | CRUD | `FW/modules/beachcomber/searchable.go` + `example_test.go` `installGate` 123-133 | exact |
+
+### Proof host (`APP/` = `sm-grzybyfunkcjonalne-app`)
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|-------------------|------|-----------|----------------|---------------|
+| `APP/summer.yaml` | config | — | same file today (add journal) | exact |
+| `APP/go.work` | config | — | same file (add `./plugins/golem15/journal`) | exact |
+| `APP/go.mod` | config | — | same file replace block 85-87 | exact |
+| `APP/.gitmodules` | config | — | same file (add journal submodule) | exact |
+| `APP/config/http.yaml` | config | request-response | same file CORS paths; add `_journal/api/*` | exact |
+| `APP/boot_test.go` | test | request-response | same file `TestBootUserTranslate` (become 3 plugins) | exact |
+| `APP/plugins.gen.go`, `APP/main.go` | route | — | same files (`summer build`; do not hand-author after first boot) | exact |
+
+### Tests (plan 04 last)
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|-------------------|------|-----------|----------------|---------------|
+| `JR/admin_harness_test.go` + Posts/Categories/Tags admin tests | test | request-response | `TR/admin_harness_test.go` `newAdminEnv` 172-182 | exact |
+| `JR/updates/postgres_test.go` | test | CRUD | `TR/updates/postgres_test.go` TestMain + testcontainers | exact |
+| `JR/controllers/api/*_test.go` JOURNAL-005/006, 401 shape, per_page 9/30 | test | request-response | PHP `tests/security/` + `USR/register_test.go` `TestRegisterCORSPath` 263-271 | exact |
+| `JR/models/*_test.go` Fillable / Translatable / MorphName | test | CRUD | `USR/models/admin_models_test.go` FilterScopes | exact |
+| `APP/boot_test.go` (3 plugins + CORS + controller IDs) | test | — | same file (modify) | exact |
+
+### Framework (`FW/`) — do not create
+
+| File | Role | Data Flow | Closest Analog | Match Quality |
+|------|------|-----------|----------------|---------------|
+| cabana markdown / mlmarkdown | — | — | already shipped Phase 14.2.1 | n/a (no-op) |
+
+## Pattern Assignments
+
+### `JR/go.mod` (config) — plan 01
+
+**Analog:** `TR/go.mod` lines 1-14, 122
+
+```
+module git.golem15.com/golem15/sm-translate-plugin
+
+go 1.27.0
+
+require (
+ git.golem15.com/golem15/summercms v0.0.0
+ github.com/go-gormigrate/gormigrate/v2 v2.1.7
+ ...
+ gorm.io/gorm v1.31.2
+)
+
+replace git.golem15.com/golem15/summercms => ../summercms.go
+```
+
+Copy: module `git.golem15.com/golem15/sm-journal-plugin`, Go 1.27.0, require GORM + gormigrate + summercms, **identical** sibling replace `../summercms.go`. Do not add goldmark unless `FormatHTML` lives in the plugin and imports it (then pin `github.com/yuin/goldmark v1.8.6`, already `FW/go.mod` line 30). Do **not** require `sm-user-plugin` (redactor_id is a naked integer; RESEARCH pitfall 7 / anti-pattern). `TR/go.mod` currently requires user for admin harness only — Journal must not copy that unless the last-plan harness truly needs it; host tests may join `users`.
+
+Decision note: `.planning/notes/core-plugins-own-repos.md` — module path equals repo path; package `journal`; plugin ID `golem15.journal`. README never names a consuming app.
+
+---
+
+### `JR/plugin.go` (provider) — plan 01 / 03
+
+**Analog (compiled init):** `TR/plugin.go` lines 19-68
+
+```go
+var (
+ _ party.Plugin = (*Plugin)(nil)
+ _ pact.HasConfig = (*Plugin)(nil)
+ _ pact.HasMigrations = (*Plugin)(nil)
+ _ pact.HasModels = (*Plugin)(nil)
+ _ pact.HasLang = (*Plugin)(nil)
+)
+
+func (p *Plugin) ID() string { return "golem15.translate" }
+func (p *Plugin) Requires() []string { return nil }
+func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
+func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
+func (p *Plugin) Models() []any { return models.All() }
+func init() { party.Register(&Plugin{}) }
+```
+
+Journal: `ID() "golem15.journal"`, **`Requires() []string{"golem15.translate"}`**. PHP `$require` also names Apparatus — Apparatus is dissolved; do **not** require an apparatus plugin (`PHP/Plugin.php` 15-18 vs RESEARCH §2).
+
+**Analog (routes / buckets / commands):** `USR/plugin.go` lines 28-40, 145-166, 203-204 and `USR/routes.go` 9-31
+
+```go
+_ pact.HasRoutes = (*Plugin)(nil)
+_ pact.HasCommands = (*Plugin)(nil)
+_ surf.BucketProvider = (*Plugin)(nil)
+
+func (p *Plugin) Buckets() map[string]surf.Bucket {
+ trusted := surf.TrustedProxies(p.app.Config)
+ return map[string]surf.Bucket{
+ "user-api": {
+ Max: 120,
+ Decay: time.Minute,
+ Key: func(r *http.Request) string {
+ if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil {
+ return "u:" + sub
+ }
+ return surf.ClientIP(r, trusted)
+ },
+ },
+ }
+}
+```
+
+Copy two buckets named **`journal-public-api`** and **`journal-api`**, Max 120, Decay `time.Minute` (`PHP/routes.php` 7-14; `FW/modules/surf/limiter.go` 17-33). Public key is IP only. Write-bucket key is `u:` + backend principal ID when `bouncer.User(r.Context())` is a backend principal, else IP. 429 body stays surf's `{"message":"Too Many Attempts."}` (`limiter.go` 17) — do not invent a Journal 429 shape.
+
+Do **not** copy user JWT/bouncer/mail from `USR/plugin.go` Boot. Do **not** register `"backend"` middleware onto public GET (pitfall 4). Admin capability assertions live in `admin.go`. Also `_ pact.HasSettings`.
+
+**PHP contract (do not port):** `registerComponents`, Winter.Pages menu types, dashboard report widget, Scout service-provider registration as a required dep. Port Scout only as `beachcomber.Searchable` behind a Gate that defaults off.
+
+---
+
+### `JR/admin.go` + permissions + navigation — plan 02
+
+**Analog:** `TR/admin.go` 12-34, `USR/admin.go` 12-38, `USR/admin_permissions.go` 14-21, `USR/admin_navigation.go` 11-43
+
+```go
+//go:embed controllers/locales/config_list.yaml controllers/locales/config_form.yaml models/locale/fields.yaml models/locale/columns.yaml
+var adminFS embed.FS
+
+func (p *Plugin) AdminFS() fs.FS { return adminFS }
+func (p *Plugin) AdminControllers() []pact.AdminController {
+ return controllers.AdminControllers(func() *backpack.App { return p.app })
+}
+```
+
+Embed YAML **file-by-file** (controllers/ also holds `.go`). Include settings `models/settings/fields.yaml` and the SVG if AdminFS is the right tree; otherwise `//go:embed assets/images/journal-icon.svg` on the plugin for nav. Do not embed the whole `controllers/` directory.
+
+**Permissions** — copy codes exactly from `PHP/Plugin.php` 55-90:
+
+- `golem15.journal.manage_settings`
+- `golem15.journal.access_posts`
+- `golem15.journal.access_categories`
+- `golem15.journal.access_other_posts`
+- `golem15.journal.access_import_export`
+- `golem15.journal.access_publish`
+- `golem15.journal.access_tags`
+
+Tab/label phrase keys from `PHP/lang/en/lang.php` 8-24. `Roles: []string{"developer"}` like user. `cabana.Allows` is Winter `hasAnyAccess` (OR) (`FW/modules/cabana/contracts.go` 143-161).
+
+**Navigation analog:** main item `Code: "journal"`, `Permissions: []string{"golem15.journal.*"}`, `Order: 300`, `Controller: "golem15.journal.posts"`. PHP `icon-pencil` already maps to lucide `pencil` (`FW/admin/src/app/icons.ts` 273) — use `"pencil"` or `"icon-pencil"`; do not invent a pack. Side menu: `new_post` (create URL → controller create), `posts`, `categories`, `tags` with the PHP permission lists. Embed `PHP/assets/images/journal-icon.svg` (D-08). Skip dashboard widget.
+
+**Settings analog:** `FW/modules/cabana/example_controller_test.go` 120-132 and `docs/backend/settings.md` 7-29
+
+```go
+func (p *BlogPlugin) Settings() []pact.SettingsItem {
+ return []pact.SettingsItem{{
+ Code: "blog",
+ Label: "acme.blog::lang.settings.label",
+ Icon: "icon-pencil",
+ Permissions: []string{"acme.blog.access_settings"},
+ Form: "models/settings/fields.yaml",
+ NewModel: func() any { return &BlogSettings{} },
+ }}
+}
+```
+
+Journal: `Code: "journal"`, permissions `golem15.journal.manage_settings`, `Form: "models/settings/fields.yaml"`, `NewModel: func() any { return &models.Settings{} }`. Dedicated table `golem15_journal_settings` ID=1 — not `system_settings` (`PHP/models/Settings.php` 13 vs cabana docs).
+
+---
+
+### `JR/models/post.go` (model, CRUD) — plan 01/02
+
+**Analog (Go struct + Fillable + MorphName + Attach):** `USR/models/user.go` 57-71, 75-80
+
+```go
+func (User) TableName() string { return "users" }
+func (User) MorphName() string { return `Golem15\User\Models\User` }
+func (User) AttachRelations() []attach.Relation {
+ return []attach.Relation{{Name: AvatarField, Public: true}}
+}
+func (User) Fillable() []string { return []string{ "name", "surname", "email", ... } }
+```
+
+**Contract:** `PHP/models/Post.php` 37, 48-65, 102-128, 144-197
+
+- `TableName() "golem15_journal_posts"`
+- `MorphName() \`Golem15\Journal\Models\Post\`` — never `journal.Post` or `reflect.TypeOf` (pitfall 10)
+- `Translatable()`: `title`, `content`, `content_html`, `excerpt`, `metadata`
+- `TranslatableIndexes()`: `slug`
+- Admin Fillable: form columns only. **Never** fill `redactor_id`, `content_html`, `user_id` from the public API body. `user_id` stamped from backend principal on create (`PHP` beforeSave 145-150).
+- Rules: `title` required; `slug` required + regex + unique; `content` required; published+published_at together (PHP `afterValidate` 181-187) as `FormBeforeCreate`/`FormBeforeUpdate` returning `&cabana.ValidationError`
+- Relations: `user` belongsTo `cabana.BackendUser`; `categories`/`tags` belongsToMany via `golem15_journal_posts_categories` / `_tags`; `featured_images`/`content_images` attachMany (`system_files`, morph PHP class string)
+- `canEdit`: owner or `golem15.journal.access_other_posts` (194-197)
+- `FilterScopes`: `FilterPublished`, `FilterCategories` (and daterange uses `column: created_at` — no `conditions` key)
+
+**FilterScope analog:** `USR/models/user.go` 99-117
+
+```go
+func (User) FilterScopes() []string { return []string{FilterByGroup} }
+func (User) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
+ if name != FilterByGroup {
+ return db.Where("1 = 0")
+ }
+ ...
+}
+```
+
+PHP `config_filter.yaml` `conditions:` **boot-fails** in cabana (`FW/modules/cabana/filter_schema.go` 17-20). Re-express:
+
+- `published` switch → `FilterPublished` applying `published <> true` / `published = true`
+- `published_date` daterange → YAML `type: daterange` `column: created_at` (legal keys only)
+- `category` `scope: FilterCategories` — include child categories as PHP does
+
+**Searchable analog:** `FW/modules/beachcomber/searchable.go` 12-22 and `example_test.go` 31-34, 123-133
+
+```go
+func (Post) SearchableAs() string { return "acme_blog_posts" }
+func (p *Post) ShouldBeSearchable() bool { return p.Published }
+svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
+ var enabled bool
+ err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
+ return err == nil && enabled
+}))
+```
+
+Journal: `SearchableAs() "golem15_journal_posts"`. Gate reads `golem15_journal_settings.search_use_typesense` for ID=1; read errors count as off. `ShouldBeSearchable` false when unpublished **or** gate off. Fresh install never contacts Typesense.
+
+---
+
+### `JR/models/category.go` / `tag.go` (model, CRUD) — plan 01
+
+**Analog:** `USR/models/user_group.go` 24-46 + PHP fillable
+
+Category (`PHP/models/Category.php` 21, 27-40, 45-51): `TableName "golem15_journal_categories"`, Fillable **only** `name`, `slug`, `code`, `description`, `parent_id`. **Not** `nest_left`/`nest_right`/`nest_depth` (JOURNAL-002). Translatable `name`, `description`; indexes `slug`. `MorphName() \`Golem15\Journal\Models\Category\``. Keep nest_* columns in DDL; no NestedTree reorder UI; `parent_id` is a `relation`.
+
+Tag (`PHP/models/Tag.php` 14, 19-31): `TableName "golem15_journal_tags"`, Fillable **only** `name`, `slug`, `description`. **Not translatable.** Rules `name` required; `slug` required|between:3,64|unique.
+
+---
+
+### `JR/models/settings.go` (model, CRUD) — plan 02
+
+**Analog:** `FW/modules/cabana/example_controller_test.go` 58-72 + `FW/modules/cabana/settings.go` 19-26, 46-51 (Fillable + Rules required at boot)
+
+PHP `$rules` (`PHP/models/Settings.php` 17-31): `show_all_posts`, `use_rich_editor`, `search_use_typesense`, weights, RSS fields. Defaults: show_all_posts true, use_rich_editor false, search_use_typesense false, weights 5/3/1. `use_rich_editor` stored and **ignored** at compile time (always `mlmarkdown`). Settings YAML: drop every `trigger` block; always show weight fields; drop `placeholder`.
+
+---
+
+### `JR/updates/*` (migration, CRUD) — plan 01
+
+**Analog (CREATE + Register):** `TR/updates/202610060001_create_golem15_translate_locales.go` 8-38 and `TR/updates/registry.go` 7-15
+
+```go
+ID: "202610060001_create_golem15_translate_locales",
+Migrate: func(tx *gorm.DB) error {
+ for _, stmt := range []string{`CREATE TABLE golem15_translate_locales (... )`, `CREATE INDEX ...`} {
+ if err := tx.Exec(stmt).Error; err != nil { return err }
+ }
+ return nil
+},
+Rollback: func(tx *gorm.DB) error {
+ return tx.Exec(`DROP TABLE IF EXISTS golem15_translate_locales`).Error
+},
+```
+
+**Analog (ALTER another plugin's table):** `USR/updates/202609220005_extend_users.go` 10-31 — Journal's author_slug uses `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`. Do **not** put this in lagoon's framework migration. `BackendUser.TableName()` is `"backend_users"` (`FW/modules/cabana/contracts.go` 56).
+
+Squash IDs (planner may adjust date prefix, not table names):
+
+| ID | Table / change |
+|----|----------------|
+| `202610060001_create_golem15_journal_posts` | posts columns from RESEARCH §1 (incl. content_html, metadata JSONB, sources JSONB, is_pinned, redactor_id, unique slug) |
+| `202610060002_create_golem15_journal_categories` | + nest_* + unique slug |
+| `202610060003_create_golem15_journal_tags` | unique slug |
+| `202610060004_create_golem15_journal_posts_categories` | pivot |
+| `202610060005_create_golem15_journal_posts_tags` | pivot |
+| `202610060006_create_golem15_journal_settings` | typed singleton columns |
+| `202610060007_add_author_slug_to_backend_users` | ALTER |
+
+Do not emit `rainlab_journal_*`. Seed optional Uncategorized only if PHP seeder at this pin still inserts it (assumption A4). AutoMigrate is never the schema source.
+
+---
+
+### `JR/classes/format_html.go` (service, transform) — plan 02
+
+**No plugin analog.** Combine:
+
+1. **Contract:** `PHP/models/Post.php` 199-225 — footnotes + attributes + tables, then Html::clean unless `backend.allow_unsafe_markdown`. Ignore `use_rich_editor` (deferred WYSIWYG).
+2. **Reject gate analog:** `FW/modules/cabana/field_markdown.go` 11-46 — goldmark **without** `html.WithUnsafe`; reject script/iframe/event handlers/dangerous URLs.
+
+```go
+markdownEngine = goldmark.New()
+func RenderMarkdown(src string) (string, error) {
+ ...
+ if err := rejectUnsafeMarkdownHTML(html); err != nil { return "", err }
+ return html, nil
+}
+```
+
+**Do not change `cabana.RenderMarkdown` globally** (pitfall 15; mail-aligned). Put `journal.FormatHTML` in the plugin: same `github.com/yuin/goldmark` module with footnote/table/attribute extensions (assumption A1 — if a separate module is required, stop). Stored `content_html` and public API use FormatHTML. SPA preview may use `cabana.RenderMarkdown`. Call FormatHTML from admin save hooks and API writes, not only API.
+
+---
+
+### Admin controllers (controller, CRUD) — plan 02
+
+**Analog:** `USR/controllers/users_admin_controller.go` 40-96, 243-258 + `TR/controllers/locales.go` 14-33 + `USR/controllers/admin_registry.go` 12-36
+
+```go
+func (usersAdminController) ID() string { return "golem15.user.users" }
+func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
+func (usersAdminController) ConfigDir() string { return "controllers/users" }
+func (usersAdminController) RequiredPermissions() []string {
+ return []string{PermissionAccessUsers}
+}
+func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
+ return db.Unscoped()
+}
+```
+
+| PHP | Go ID | Perm | ModelName |
+|-----|-------|------|-----------|
+| Posts.php | `golem15.journal.posts` | `access_posts` | `Golem15\Journal\Models\Post` |
+| Categories.php | `golem15.journal.categories` | `access_categories` | `Golem15\Journal\Models\Category` |
+| Tags.php | `golem15.journal.tags` | `access_tags` | `Golem15\Journal\Models\Tag` |
+
+PHP Posts `$requiredPermissions` is OR of `access_other_posts` and `access_posts` (`PHP/controllers/Posts.php` 21). Cabana `Allows` is OR — `RequiredPermissions: []string{"golem15.journal.access_posts"}` for the screen; **additionally** restrict query without `access_other_posts`.
+
+**ListExtendQuery / FormExtendQuery analog:** `PHP/controllers/Posts.php` 60-72 — without `access_other_posts`, `where user_id = principal.ID`. Principal from `bouncer.User(ctx)` (`FW/modules/bouncer/context.go` 32-38).
+
+**FormBeforeCreate analog:** stamp `user_id` from backend principal if empty (`PHP/models/Post.php` 145-150). Pattern of hook shape: `USR/controllers/users_admin_controller.go` 243-258 (stamp fields, return `ValidationError`/`ForbiddenError`). Also regenerate `content_html` via FormatHTML on create/update.
+
+**Publish without permission:** return `&cabana.ForbiddenError{Message: ...}` (`FW/modules/cabana/crud.go` 71-84) on publish writes without `golem15.journal.access_publish`. Hiding fields is UX; refuse is mandatory (`PHP/models/Post.php` filterFields 164-178).
+
+**Import/export toolbar:** no cabana ImportExport behavior. `pact.HasAdminActions` (`FW/modules/pact/capabilities.go` 216-228, 266-269) with `Permissions: []string{"golem15.journal.access_import_export"}`. CLI names `journal:export-posts` / `journal:import-posts` (`PHP/Plugin.php` 169-170). Command analog: `USR/console/require_password_change.go` 16-25 (`bonfire.Command{Name, Description, Args, Run}`). Port PHP `PostImport`/`PostExport` column sets; do not invent a generic CSV framework.
+
+Error types: `FW/modules/cabana/crud.go` 65-84 `ValidationError` (422) / `ForbiddenError` (403).
+
+---
+
+### Admin YAML (config, file-I/O) — plan 02
+
+**Go analog (list/form):** `USR/controllers/users/config_list.yaml` 1-16, `config_form.yaml` 1-17, `config_filter.yaml` 1-14 (legal keys only: `label`, `type`, `column`, `modelClass`, `nameFrom`, `scope`). `TR/controllers/locales/config_list.yaml` `recordUrl` / `defaultSort`.
+
+**Go analog (fields):** `USR/models/user/fields.yaml` 38-57 (`type: relation`, `type: fileupload` `mode: image`) and `FW/docs/backend/forms.md` 332-346:
+
+```yaml
+fields:
+ title:
+ type: mltext
+ body:
+ type: mlmarkdown
+ size: huge
+```
+
+**PHP contract to rewrite, not copy:** `PHP/models/post/fields.yaml` uses boot-fail keys (`placeholder`, `cssClass`, `stretch`, `commentAbove`, `trigger`, `type: taglist`, `type: repeater`, widget class, `tabs.stretch`). Cabana allow-list: `FW/modules/cabana/form_schema.go` 24-47.
+
+| PHP field | Go YAML |
+|-----------|---------|
+| `title` | `type: mltext` |
+| `slug` | `type: mltext` + `preset` field title type slug |
+| `content` | `type: mlmarkdown` — not JournalMarkdown widget |
+| `excerpt` | `type: mltext` |
+| `categories` | `type: relation` `nameFrom: name` |
+| `tags` | `type: relation` `nameFrom: name` — **not** `taglist`; create tags on Tags admin |
+| `published` | `type: switch` or `checkbox` |
+| `is_pinned` | `type: checkbox` |
+| `user` | `type: relation` `nameFrom: login` `emptyOption` current user |
+| `published_at` | `type: datepicker` `mode: datetime`. Drop `trigger`. |
+| `featured_images` | `type: fileupload` `mode: image` `imageWidth`/`imageHeight` 200 |
+| `sources` repeater | **Omit from admin form.** Keep JSONB; write API still accepts `sources`. |
+| `metadata[preview_page]` | **Omit** (Phase 16). Keep `metadata` JSONB. |
+| `toolbar` partial | Omit |
+
+List: `type: date` is legal (`FW/modules/cabana/list_schema.go` 22-24). Unpublished row class → `pact.RowStateDisabled` if wired (`USR` ListRowStates 98-125); else skip. `config_list.yaml` `recordUrl: golem15/journal/posts/update/:id` like users/locales. PHP `recordsPerPage: 25` (`PHP/controllers/posts/config_list.yaml` 21).
+
+Filters: **no `conditions:`**. Analog `USR/controllers/users/config_filter.yaml`:
+
+```yaml
+scopes:
+ groups:
+ type: (omit — modelClass + scope)
+ modelClass: Golem15\User\Models\UserGroup
+ nameFrom: name
+ scope: filterByGroup
+ created_date:
+ type: daterange
+ column: created_at
+ activated:
+ type: switch
+ column: is_activated
+```
+
+---
+
+### `JR/routes.go` + API controllers (controller, request-response) — plan 03
+
+**Analog (group + throttle + Where):** `USR/routes.go` 9-31 and `FW/modules/surf/example_test.go` 75-88
+
+```go
+func (p *Plugin) Routes(r pact.Router) error {
+ r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
+ g.Get("/fetch", controllers.Fetch(p.app))
+ g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
+ g.Where("id", "[0-9]+")
+ })
+ return nil
+}
+```
+
+**Contract:** `PHP/routes.php` 16-67. Two groups, same prefix `/_journal/api/v1`:
+
+1. Public: `throttle:journal-public-api` **only**. GET `posts`, `posts/{slug}` with `Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `categories`, `tags`, `rss`.
+2. Writes: required backend principal + `throttle:journal-api`. POST/PUT/DELETE posts, featured-images, media/upload. `Where("id", `[0-9]+`)`.
+
+Do **not** attach cabana `"backend"` middleware (writes `WriteError` 401 `unauthenticated` / `"Unauthenticated"` — `FW/modules/cabana/http.go` 200-201, `contracts.go` 215-231). PHP shape is flat `{"error":"..."}`.
+
+**Flat JSON analog:** `USR/controllers/api_tokens.go` 96 (`writeJSON(w, 404, map[string]any{"error": "Token not found"})`) and `USR/controllers/api_controller.go` 1025-1028 (`wire.WriteJSON`). **Not** `cabana.WriteError`.
+
+**PHP error strings** (`PHP/controllers/api/PostApiController.php` 237, 263, 268; MediaApi 26-41; show 204-216):
+
+- 401 `{"error":"Authentication required"}`
+- 403 `{"error":"Insufficient permissions"}` / `{"error":"You do not have permission to publish posts"}`
+- 404 `{"error":"Post not found"}` — **no `data` key** on drafts (JOURNAL-005)
+- 422 `{"error":"Validation failed","errors":{...}}`
+
+**Write auth:** verify cabana backend JWT (HS256, audience `backend`, blacklist `backend_jwt_blacklist`). Analog: `FW/modules/bouncer/jwt.go` 90-104 `NewBackendJWTGuard` + `FW/modules/cabana/http.go` 137-153 (cabana registers `"backend"`). Journal write handlers Lookup `*bouncer.Registry`, Authenticate the `"backend"` guard **in-handler** (or a plugin middleware that writes PHP JSON, not cabana's `writeUnauthenticated`). D-15: backend Bearer only — not `user.api_token`, not `g15_`.
+
+**Optional editor on public GET:** `PHP/controllers/api/PostApiController.php` `isEditor` 685-688. Do **not** attach `"backend"` (missing token would 401). If `Authorization: Bearer` is present, verify with the same backend JWT and `bouncer.WithUser`; otherwise anonymous. Analog pieces: `bouncer.Registry.Middleware` 67-101 (on failure **without** UnauthorizedWriter, next runs unauthenticated — but cabana's guard **does** implement UnauthorizedWriter, so attaching it 401s). Therefore: call `Guard.Authenticate` only when the header is present; on failure treat as anonymous for GET (do not write 401 on public GET). `bouncer.User(ctx)` (`context.go` 32-38) afterwards. `cabana.Allows(pr, []string{"golem15.journal.access_posts"})`.
+
+**Draft visibility (JOURNAL-005):** unpublished **or** `published_at` in the future. Show 404 no `data` unless owner or `access_other_posts` (`PHP` 207-217, `canViewDraft` 696-708). Index: anonymous `published=true`; editor default includes drafts unless `?published=true`. `per_page` default **9** max **30** (`PHP` 34) — not API.md 15/50.
+
+**Serialize:** do not apply `UNPUBLISHED_TITLE_PREFIX` on API JSON. Include `previous_post`, `next_post`, `related_posts` on show. Translations object on write via translate plugin helpers (`classes.SetTranslated`), not extra columns. Field-by-field assign on store (`PHP` 276-287) — never `lagoon.Fill` the whole body onto Post.
+
+**RSS:** ship GET `rss` as XML (`PHP/routes.php` 33-35). No Go analog; stdlib `encoding/xml`.
+
+---
+
+### `JR/controllers/api/media.go` (controller, file-I/O) — plan 03
+
+**Contract:** `PHP/controllers/api/MediaApiController.php` 21-62. Require backend user + `access_posts`; folder regex `^[A-Za-z0-9_\-\/]*$`; force under `journal/`; strip leading `journal`; reject `..`. Image mimes jpg/jpeg/png/gif/webp max 10240 KB. 201 `{data:{url,path}}`. Use gocloud blob + host `upload_bytes` but still enforce 10MB in the handler. Analog blob/size: `USR` avatar (`avatarMaxBytes` in `api_controller.go` 37) — copy the permission/path rules from PHP, not the avatar field.
+
+---
+
+### `JR/console/*` (utility, batch) — plan 02
+
+**Analog:** `USR/console/require_password_change.go` 16-25 + `USR/plugin.go` 203-204 `Commands() []bonfire.Command`. Names from `PHP/Plugin.php` 169-170 and `PHP/console/ExportPosts.php` 11-15 (`journal:export-posts`, `--path`, `--dry-run`). Register via `pact.HasCommands` (`FW/modules/pact/capabilities.go` 15-18).
+
+---
+
+### Phrasebook + README — plan 01/02
+
+**Lang analog:** `TR/lang/en/lang.yaml` — `plugin:` + screen keys. Port `PHP/lang/en/lang.php` and `lang/pl/lang.php` only (D-06). Do not load the other 19 PHP locales.
+
+**README analog:** `TR/README.md` 1-15, 30-73 — H1 plugin name, one-sentence summary, import line, Overview/Features/Usage. Neutral `the application`, example `blog` / `acme`. **Never** name grzybyfunkcjonalne or Płytarium (pitfall 13). Document Phase 16 successor for Winter components (`journalPost`, …) in a short out-of-scope note. MorphName example for Journal must be the PHP class string, not the translate README's `Acme\Blog\Models\Post` except as a generic illustration in framework docs.
+
+---
+
+### Proof host (`APP/`) — plan 01
+
+**Analog:** current `APP/summer.yaml` 1-7, `APP/go.work` 5-9, `APP/go.mod` 1-11 and 85-87, `APP/.gitmodules` 1-8, `APP/config/http.yaml` 1-13, `APP/boot_test.go` 17-90, `APP/plugins.gen.go` 1-14
+
+Today:
+
+```yaml
+plugins:
+ - id: golem15.user
+ module: git.golem15.com/golem15/sm-user-plugin
+ - id: golem15.translate
+ module: git.golem15.com/golem15/sm-translate-plugin
+```
+
+Add:
+
+```yaml
+ - id: golem15.journal
+ module: git.golem15.com/golem15/sm-journal-plugin
+```
+
+`go.work` `use ./plugins/golem15/journal`. `go.mod` `require` + `replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal`. `.gitmodules` path `plugins/golem15/journal` url `git@git.golem15.com:golem15/sm-journal-plugin.git`. CORS add `_journal/api/*`; keep `supports_credentials: false`.
+
+`boot_test.go`: `len(plugins) != 2` → 3; allow `golem15.journal`; assert controller IDs `golem15.journal.posts|categories|tags` and that `/_journal/api/v1` routes exist; `assertGitlink` for journal. Today's test **forbids** `golem15.journal` (line 40-42) — change first in 15-01 (pitfall 8).
+
+`plugins.gen.go` / `main.go`: regenerate with `summer build`; stamp `// Code generated by summer build. DO NOT EDIT.` Host README may name itself; plugin README must not.
+
+---
+
+### Tests — plan 04
+
+**Admin boot analog:** `TR/admin_harness_test.go` 172-182 `newAdminEnv` — `party.Activate`, `lagoon.Migrate`, mint backend admin with a permission set. Posts 403 without `access_posts`.
+
+**Postgres analog:** `TR/updates/postgres_test.go` 25-36 TestMain + testcontainers, `-short` skip.
+
+**CORS analog:** `USR/register_test.go` 263-271 `TestRegisterCORSPath` — read host `config/http.yaml`, require `_journal/api/*` (and keep `_user/api/*`).
+
+**PHPUnit map:** RESEARCH §8. JOURNAL-005 draft 404; JOURNAL-006 media 403 + path prefix; JOURNAL-001/002 fillable; FormatHTML rejectUnsafe substitutes JOURNAL-003/004 templates (Phase 16). Redactor_id column exists, not Fillable. Also: limiter names, anonymous list hides drafts, write without Bearer 401 PHP shape, publish without `access_publish` 403, YAML compile of adapted fields, `mlmarkdown` save through TranslationWriter, Typesense gate off (zero HTTP).
+
+## Shared Patterns
+
+### Plugin mount (submodule + go.work + replace)
+
+**Source:** `TR/go.mod` line 122, `APP/go.mod` 85-87, `APP/go.work` 5-9, `APP/summer.yaml`, `.planning/notes/core-plugins-own-repos.md`
+**Apply to:** `JR/` repo creation and `APP/` journal mount
+
+Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, ID `golem15.journal`, `party.Register` in `init`, `Requires` translate. Sibling replace `../summercms.go`. Host replace `./plugins/golem15/journal`. Submodule `plugins/golem15/journal`.
+
+### Admin CRUD + permissions
+
+**Source:** `USR/admin.go`, `users_admin_controller.go`, `pact.HasAdminControllers` / `AdminPermissioned` (`FW/modules/pact/capabilities.go` 184-197)
+**Apply to:** Posts, Categories, Tags
+
+YAML + `CRUDService`. `RequiredPermissions`. Fillable allow-list. Lifecycle hooks return `ValidationError` (422) or `ForbiddenError` (403). Fail-loud YAML at `cabana.Activate`.
+
+### Translatable + MorphName
+
+**Source:** `TR/classes/translatable.go` 16-24; `USR/models/user.go` 59-60; `TR/README.md` 70-72
+**Apply to:** Post, Category
+
+Implement `Translatable()`, `TranslatableIndexes()`, `MorphName()` with PHP class strings. Default locale on host columns; other locales via translate plugin. Do not invent `title_pl` columns.
+
+### Public plugin HTTP vs cabana admin envelope
+
+**Source:** `USR/routes.go` + `USR/controllers/api_tokens.go` flat `{error}`; `FW/modules/cabana/contracts.go` 215-231 (do **not** use on `/_journal/api/v1`)
+**Apply to:** all Journal API handlers
+
+Cabana SPA admin uses `{error:{code,message,details}}`. Journal API uses PHP `{error: string}`. Two envelopes, one binary.
+
+### Rate limits
+
+**Source:** `USR/plugin.go` Buckets 145-166; `FW/modules/surf/limiter.go` 17-33
+**Apply to:** `journal-public-api` / `journal-api`
+
+Named `surf.BucketProvider`. TrustedProxies + ClientIP. 429 framework shape.
+
+### Backend principal
+
+**Source:** `FW/modules/bouncer/context.go` 24-38; `jwt.go` 90-104; `cabana/http.go` 137-153; `cabana/contracts.go` Allows 143-161
+**Apply to:** write API (required), public GET (optional), admin ListExtendQuery
+
+Writes: authenticate backend JWT, 401 PHP string if missing. Public GET: optional Authenticate when Bearer present; never 401 anonymous. Admin: `bouncer.User` + `cabana.Allows`.
+
+### Search gate
+
+**Source:** `FW/modules/beachcomber/searchable.go` Gate 146-163; `example_test.go` installGate
+**Apply to:** Post Searchable
+
+Off by default. Errors count as off. Unpublished not indexed.
+
+### Settings singleton
+
+**Source:** `FW/docs/backend/settings.md`; `example_controller_test.go` BlogSettings / Settings()
+**Apply to:** `golem15_journal_settings` ID=1
+
+`pact.HasSettings`. Fillable + Rules. No `system_settings`.
+
+### Test harness
+
+**Source:** `TR/admin_harness_test.go` `newAdminEnv`; `TR/updates/postgres_test.go`
+**Apply to:** plugin admin + migration + API tests last
+
+`party.Activate` + `lagoon.Migrate` + `surf.Assemble`. testcontainers behind `-short`.
+
+### Markdown
+
+**Source:** `FW/modules/cabana/field_markdown.go`; `FW/go.mod` goldmark v1.8.6
+**Apply to:** admin field type (existing `mlmarkdown`) vs stored HTML (plugin FormatHTML)
+
+One library. Two pipelines. Do not merge them.
+
+## No Analog Found
+
+| File | Role | Data Flow | Reason |
+|------|------|-----------|--------|
+| `JR/classes/format_html.go` (goldmark footnote/table/attribute + rejectUnsafe, not cabana.RenderMarkdown) | service | transform | No plugin currently enables goldmark extensions. Copy PHP `formatHtml` contract and cabana's rejectUnsafe helper; do not edit `field_markdown.go`. |
+| `JR` RSS XML handler | controller | request-response | No compiled plugin serves RSS. Use stdlib `encoding/xml` and PHP `PostApiController::rss` as the contract. |
+
+Planner should use RESEARCH.md §4–§5 for those two.
+
+## Do not copy / anti-patterns
+
+- **Byte-copy PHP YAML** — `taglist`, `repeater`, `trigger`, `placeholder`, `conditions` fail boot.
+- **Attaching `"backend"` middleware to public GET** — anonymous 401 (pitfall 4).
+- **Cabana `WriteError` envelope on `/_journal/api/v1`** — PHP `{error}` string (pitfall 3).
+- **`user.api_token` or `g15_`** — D-15.
+- **Replaying `rainlab_journal_*` table names.**
+- **Changing `cabana.RenderMarkdown` to enable footnotes globally** (pitfall 15).
+- **Adding a second markdown YAML type** — D-11 shipped.
+- **Importing `sm-user-plugin` only to type `redactor`.**
+- **`replace … => ../../../../summercms.go` in the sibling checkout** — use `../summercms.go` (pitfall 9).
+- **MorphName `journal.Post`** — PHP class strings (pitfall 10).
+- **Typesense on by default** (pitfall 11).
+- **API.md per_page 15/50** — controller 9/30 (pitfall 12).
+- **Naming the host in the plugin README** (pitfall 13).
+- **Editing `wn-journal-plugin` / PHP tree** (D-07).
+- **Adding Journal to the tide 154-route harness** (D-16).
+- **Porting Translate, Pages menu types, dashboard widgets, or remaining 19 locales.**
+- **Hand-authoring `plugins.gen.go` after first `summer build`.**
+- **AutoMigrate as schema.**
+
+## Metadata
+
+**Analog search scope:** `TR/` (sm-translate-plugin), `USR/` (sm-user-plugin), `APP/` (sm-grzybyfunkcjonalne-app), `FW/modules/{cabana,pact,surf,bouncer,beachcomber,lagoon}`, `FW/docs/backend`, `FW/admin/src/app/icons.ts`, `PHP/` journal plugin at `02110eb1c0c3861370b0b9b47b209a0702ac5d88`
+**Files scanned:** ~70 analog files read or grepped; 3–5 strong matches per new file; PHP pin SHA verified
+**Pattern extraction date:** 2026-10-06
+**Tracked-source gate:** every analog path printed by `git ls-files` in its own repository
diff --git a/.planning/phases/15-journal-plugin/15-RESEARCH.md b/.planning/phases/15-journal-plugin/15-RESEARCH.md
index 31c74e3..7aaf702 100644
--- a/.planning/phases/15-journal-plugin/15-RESEARCH.md
+++ b/.planning/phases/15-journal-plugin/15-RESEARCH.md
@@ -844,20 +844,20 @@ DATA_c6d9k2hx_END
**If this table is empty:** not applicable — four assumptions remain.
-## Open Questions
+## Open Questions (RESOLVED)
1. **Should plan 01 still include "add cabana markdown"?**
- What we know: 14.2.1 already shipped the types and SPA controls.
- What's unclear: whether the planner treats D-11 as a checkbox that needs a tagged no-op.
- - Recommendation: no-op D-11; Journal uses `mlmarkdown`. Mention in plan 02 YAML only.
+ - RESOLVED: no-op D-11; Journal uses `mlmarkdown`. Mention in plan 02 YAML only.
2. **On-the-fly tags (`customTags: true`)**
- What we know: cabana has no taglist type.
- - Recommendation: relation picker + Tags admin. Do not add a framework type this phase.
+ - RESOLVED: relation picker + Tags admin. Do not add a framework type this phase.
3. **Optional backend principal helper**
- What we know: cabana guard is all-or-nothing middleware.
- - Recommendation: small function in the journal plugin that Lookup's `bouncer.Registry` guard `"backend"` if the header is present.
+ - RESOLVED: small function in the journal plugin that Lookup's `bouncer.Registry` guard `"backend"` if the header is present.
These are execution details inside Claude's Discretion, not blockers.