@cogenta/core 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +3532 -0
  2. package/dist/config/env.d.ts +21 -0
  3. package/dist/config/env.d.ts.map +1 -1
  4. package/dist/config/env.js +72 -0
  5. package/dist/config/env.js.map +1 -1
  6. package/dist/config/index.d.ts +2 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +1 -0
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/config/load-config.d.ts +9 -0
  11. package/dist/config/load-config.d.ts.map +1 -1
  12. package/dist/config/load-config.js +39 -15
  13. package/dist/config/load-config.js.map +1 -1
  14. package/dist/config/resolve-config.d.ts.map +1 -1
  15. package/dist/config/resolve-config.js +39 -0
  16. package/dist/config/resolve-config.js.map +1 -1
  17. package/dist/config/schema.d.ts +49 -0
  18. package/dist/config/schema.d.ts.map +1 -1
  19. package/dist/config/schema.js +149 -1
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/config/secret-hygiene.d.ts +55 -0
  22. package/dist/config/secret-hygiene.d.ts.map +1 -0
  23. package/dist/config/secret-hygiene.js +94 -0
  24. package/dist/config/secret-hygiene.js.map +1 -0
  25. package/dist/config/types.d.ts +166 -0
  26. package/dist/config/types.d.ts.map +1 -1
  27. package/dist/config/types.js +4 -0
  28. package/dist/config/types.js.map +1 -1
  29. package/dist/drivers/index.d.ts +1 -1
  30. package/dist/drivers/index.d.ts.map +1 -1
  31. package/dist/drivers/index.js.map +1 -1
  32. package/dist/drivers/registry.d.ts.map +1 -1
  33. package/dist/drivers/registry.js +15 -8
  34. package/dist/drivers/registry.js.map +1 -1
  35. package/dist/drivers/types.d.ts +23 -0
  36. package/dist/drivers/types.d.ts.map +1 -1
  37. package/dist/errors/codes.d.ts +1 -1
  38. package/dist/errors/codes.d.ts.map +1 -1
  39. package/dist/errors/codes.js +244 -0
  40. package/dist/errors/codes.js.map +1 -1
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/logger/error-log.d.ts +48 -0
  46. package/dist/logger/error-log.d.ts.map +1 -0
  47. package/dist/logger/error-log.js +57 -0
  48. package/dist/logger/error-log.js.map +1 -0
  49. package/dist/logger/index.d.ts +2 -0
  50. package/dist/logger/index.d.ts.map +1 -1
  51. package/dist/logger/index.js +1 -0
  52. package/dist/logger/index.js.map +1 -1
  53. package/dist/media/exif.d.ts +46 -0
  54. package/dist/media/exif.d.ts.map +1 -0
  55. package/dist/media/exif.js +291 -0
  56. package/dist/media/exif.js.map +1 -0
  57. package/dist/media/folder-path.d.ts +16 -0
  58. package/dist/media/folder-path.d.ts.map +1 -0
  59. package/dist/media/folder-path.js +51 -0
  60. package/dist/media/folder-path.js.map +1 -0
  61. package/dist/media/folder-store.d.ts +26 -0
  62. package/dist/media/folder-store.d.ts.map +1 -0
  63. package/dist/media/folder-store.js +361 -0
  64. package/dist/media/folder-store.js.map +1 -0
  65. package/dist/media/index.d.ts +5 -2
  66. package/dist/media/index.d.ts.map +1 -1
  67. package/dist/media/index.js +4 -1
  68. package/dist/media/index.js.map +1 -1
  69. package/dist/media/sql-fragments.d.ts +19 -0
  70. package/dist/media/sql-fragments.d.ts.map +1 -0
  71. package/dist/media/sql-fragments.js +29 -0
  72. package/dist/media/sql-fragments.js.map +1 -0
  73. package/dist/media/store.d.ts +1 -0
  74. package/dist/media/store.d.ts.map +1 -1
  75. package/dist/media/store.js +175 -20
  76. package/dist/media/store.js.map +1 -1
  77. package/dist/media/types.d.ts +148 -0
  78. package/dist/media/types.d.ts.map +1 -1
  79. package/dist/queue/bullmq.d.ts +4 -0
  80. package/dist/queue/bullmq.d.ts.map +1 -1
  81. package/dist/queue/bullmq.js +53 -10
  82. package/dist/queue/bullmq.js.map +1 -1
  83. package/dist/queue/database.d.ts.map +1 -1
  84. package/dist/queue/database.js +32 -11
  85. package/dist/queue/database.js.map +1 -1
  86. package/dist/queue/types.d.ts +19 -0
  87. package/dist/queue/types.d.ts.map +1 -1
  88. package/dist/rate-limit/index.d.ts +21 -0
  89. package/dist/rate-limit/index.d.ts.map +1 -0
  90. package/dist/rate-limit/index.js +25 -0
  91. package/dist/rate-limit/index.js.map +1 -0
  92. package/dist/rate-limit/memory.d.ts +17 -0
  93. package/dist/rate-limit/memory.d.ts.map +1 -0
  94. package/dist/rate-limit/memory.js +61 -0
  95. package/dist/rate-limit/memory.js.map +1 -0
  96. package/dist/rate-limit/redis.d.ts +55 -0
  97. package/dist/rate-limit/redis.d.ts.map +1 -0
  98. package/dist/rate-limit/redis.js +134 -0
  99. package/dist/rate-limit/redis.js.map +1 -0
  100. package/dist/rate-limit/types.d.ts +52 -0
  101. package/dist/rate-limit/types.d.ts.map +1 -0
  102. package/dist/rate-limit/types.js +2 -0
  103. package/dist/rate-limit/types.js.map +1 -0
  104. package/dist/version.d.ts +39 -0
  105. package/dist/version.d.ts.map +1 -0
  106. package/dist/version.js +84 -0
  107. package/dist/version.js.map +1 -0
  108. package/package.json +3 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,3532 @@
1
+ # @cogenta/core
2
+
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`89e7579`](https://github.com/cogenta-cms/cogenta/commit/89e7579129712a5978ff57b884151731f5c340ea) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Fiche feedback, two bugs reported live from the same test session: "je ne sais pas si le
8
+ traitement est en cours ou pas" (no feedback during a long agent run or theme generation),
9
+ and a reference screenshot attached to "personnalise le theme actuel pour qu'il soit comme
10
+ cette capture" that only ever changed accent colors, never the layout it showed.
11
+
12
+ **The reference-image bug, fixed**: `propose-theme.ts`'s `chooseTheme()` step already
13
+ received an attached image, but `generateSkinCandidates()` (the step that actually
14
+ produces the color/font/spacing tokens) never did — `generateSkin` had zero image support
15
+ at all. `@cogenta/agents`'s `GenerateSkinOptions`/`GenerateSkinCandidatesOptions` gain an
16
+ optional `images: readonly ChatImagePart[]`, threaded through to the model call alongside
17
+ an explicit "derive the colour palette from it" instruction when present.
18
+
19
+ **What is still a genuine limit, not fixed here**: a theme's page *structure* (which
20
+ blocks render where) is owned by the installed theme package's own render code — contract
21
+ D only ever describes tokens (color, font, spacing, radius, shadow, motion). No amount of
22
+ image analysis can turn a token-generation call into a layout generator; that would be a
23
+ different, much larger feature.
24
+
25
+ **Live progress**: new `@cogenta/agents` module `progress/` — `ProgressReporter`,
26
+ `ProgressEvent`, `createProgressJobStore<TResult>()` (in-memory, per-process; a progress
27
+ job is short-lived and watched by one open tab, unlike `@cogenta/core`'s durable `queue`).
28
+ `RunAgentLoopInput.onProgress`/`RunAgentOptions.onProgress` report `"Thinking…"`,
29
+ `"Calling tool "X"…"`, retry attempts, and tool outcomes as the agent loop runs — the same
30
+ engine behind both agent chat and every other agent, so instrumenting it once covers both.
31
+ `GenerateSkinCandidatesOptions.onProgress` and `ProposeThemeCandidatesInput.onProgress`
32
+ report each design direction as it starts/finishes. `RetryOptions.onRetry` is a new hook
33
+ `retryModelCall` invokes before backing off.
34
+
35
+ `@cogenta/core` gains two error codes, `AGENT_RUN_JOB_UNKNOWN` and
36
+ `THEME_GENERATE_JOB_UNKNOWN` (mapped to 404).
37
+
38
+ `@cogenta/api` adds three watchable job route pairs, additive alongside the existing
39
+ synchronous ones (nothing is removed or changed for a caller that doesn't care about
40
+ progress): `POST/GET /api/agents/:name/conversation/jobs[/:jobId]`,
41
+ `POST/GET /api/agents/:name/run/jobs[/:jobId]`, `POST/GET /api/theme/generate/jobs[/:jobId]`.
42
+ `AgentsRouterOptions`/`ThemeRouterOptions` gain an optional `progressJobs` store; without
43
+ one, only the job routes are unavailable — the pre-existing synchronous routes are
44
+ untouched.
45
+
46
+ `@cogenta/cli` wires `createProgressJobStore()` into `cogenta serve`'s agents and theme
47
+ routers, and forwards `onProgress` through `agent-runtime.ts`'s `AgentRunnerLike` adapter
48
+ (a real gap caught by an e2e test: the adapter's `run()` silently dropped the parameter
49
+ before this fix).
50
+
51
+ Admin UI (private, no changeset): the floating chat widget and the theme generator
52
+ workshop both poll their job's `GET` endpoint and render a live, growing progress list
53
+ (`data-testid="agent-chat-progress"` / `"theme-generator-progress"`, both `aria-live="polite"`)
54
+ instead of a static "thinking…" placeholder.
55
+
56
+ ## 0.6.0
57
+
58
+ ### Minor Changes
59
+
60
+ - [`b85ce4e`](https://github.com/cogenta-cms/cogenta/commit/b85ce4edad72ff065cd63c852a9f42aeefc5ab9a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - A tax rule or shipping method could previously only be created or deleted — fixing a typo'd rate, label, or zone meant deleting and recreating it, losing its `createdAt` and (for a shipping method) its `position` among the other methods.
61
+
62
+ - `TaxStore.updateRule()` and `ShippingStore.updateMethod()` (`@cogenta/commerce`) accept a tri-state patch: a field absent from the patch is left exactly as saved, and for the nullable fields (`country`/`region`/`freeOverMinor`/`carrier`) an explicit `null` clears them back to what an absent field already means at creation time.
63
+ - `PATCH /api/commerce/tax/rules/:id` and `PATCH /api/commerce/shipping/methods/:id` carry the same semantics — still gated on `commerce.catalog.write`, unchanged authorization.
64
+ - `@cogenta/core` gains the `COMMERCE_TAX_RULE_UNKNOWN` error code for an edit naming an id that was never a rule (shipping reuses the existing `COMMERCE_SHIPPING_METHOD_UNKNOWN`).
65
+ - Admin: an "Edit" action on each tax rule and shipping method row opens a dialog — pre-filled from the row — that saves through this PATCH path.
66
+
67
+ - [`bde02b5`](https://github.com/cogenta-cms/cogenta/commit/bde02b518f98a8d4cbc58544ea809c658b8dee7b) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Every hardcoded value that controlled an LLM call's behaviour (output token budget, retry/correction attempts, request timeout) is now a real, admin-configurable property of the provider, editable from `/admin/providers` — never a number guessed in code and shared across every model an operator might configure.
68
+
69
+ An audit of the whole `@cogenta/agents` call surface found the same class of bug independently reproduced and patched in more than one place with a different hardcoded constant each time (theme/skin generation, the generic agent runtime, the delegated-subagent budget, the `assist.*` toolset, the LangGraph loop's per-call timeout) — always the same root cause: a reasoning-tier model (confirmed live against DeepSeek) spends thousands of tokens "thinking" before writing a visible answer, and a fixed ceiling sized for a plain instruct model truncates it to an empty response.
70
+
71
+ - `ProviderClient` gains `maxOutputTokens`/`requestTimeoutMs`/`maxCorrectionAttempts` — resolved once per client from the admin's saved provider config (`StoredProviderConfig`, `ProviderConfigInput`), falling back to a built-in default only when unset. `ChatRequest.maxTokens` is now optional; when a caller omits it, the resolved client's own budget applies.
72
+ - `createAnthropicClient`/`createOpenAiClient`/`createGoogleClient` accept these three as config and apply them to every request and to the per-call HTTP timeout (`requestSignalWithTimeout`).
73
+ - Every call site that used to hardcode its own `MAX_TOKENS`/`DEFAULT_MAX_ATTEMPTS` (skin generation, the base-theme choice, brief analysis, content-model/demo-content proposals, the generic agent loop, the delegated-subagent tool, the `assist.*` toolset) now defers to the resolved client instead — removing eight separately-guessed numbers, not just the one already fixed for skin generation.
74
+ - New error code `PROVIDER_TUNING_INVALID` — a saved value outside sane bounds (1-200000 tokens, 1-600000ms, 1-10 attempts) or not a whole number.
75
+ - Deliberately left as fixed policy, not exposed per provider: the LangGraph loop's tool-call step ceiling (`maxSteps`) and repetition guard (`maxRepeats`), and the theme generator's candidate-count bounds/image-generation dimensions — these are product/orchestration decisions, not a fact about which model an admin chose.
76
+
77
+ ## 0.5.0
78
+
79
+ ### Minor Changes
80
+
81
+ - 5c5ffbd: L21 task 2 — a runtime template + personalisation system for the admin's
82
+ own interface, the counterpart `packages/admin/src/routes/appearance.tsx`
83
+ already gave the public site (contract D) but the admin itself never had:
84
+ before this, `theme.css` was a single hard-coded design with no selector
85
+ and no override mechanism at all.
86
+
87
+ **`@cogenta/core`:** two new error codes, `ADMIN_THEME_TEMPLATE_UNKNOWN`
88
+ and `ADMIN_THEME_INVALID`.
89
+
90
+ **`@cogenta/schema`:** a new `admin-theme-templates.ts` — two complete,
91
+ built-in token sets (`ADMIN_THEME_TEMPLATES`): "Nightops" (the current
92
+ dark-first, signal-green console — copied verbatim from `theme.css`) and
93
+ "Atelier" (the warm, printed-paper design that shipped immediately before
94
+ the Nightops reskin, recovered from git history rather than approximated
95
+ from memory) — plus `adminThemeOverridesSchema`, the small, curated set of
96
+ personalisation levers a template can be customised with (primary/
97
+ background/text colour, display font, body font, corner radius, an
98
+ optional logo media id) without ever rewriting the built-in template
99
+ itself. `ensureAdminThemeTable`/`createAdminThemeStore` persist exactly one
100
+ choice (a template id plus its overrides) in a new fixed table
101
+ (`cogenta_admin_theme`, the same one-table-no-migration-file treatment
102
+ `menu-tables.ts`/`site-settings-tables.ts` already use for admin-editable,
103
+ non-schema-declared state).
104
+
105
+ **`@cogenta/api`:** `createAdminThemeRouter` — `GET|PUT /api/admin-theme`.
106
+ Read needs no session at all (the admin's own `/login` screen has to paint
107
+ in the chosen template before one exists); write needs the `admin` role,
108
+ checked by the router itself.
109
+
110
+ **`@cogenta/cli`:** `cogenta serve` mounts the new store and router, and
111
+ audits every successful `PUT` the same way `/api/settings` already does.
112
+
113
+ No breaking changes — a site that never calls `PUT /api/admin-theme` keeps
114
+ `theme.css`'s own "Nightops" defaults exactly as before. `@cogenta/admin`
115
+ (private, no changeset) gains the settings screen ("Apparence de l'admin",
116
+ deliberately a separate nav entry from the public site's own "Apparence"),
117
+ `AdminThemeProvider` (injects the computed CSS as a `<style>` tag,
118
+ cascading over `theme.css`'s own tokens), and a personalised logo in the
119
+ top bar when one is set.
120
+ - 0e88f30: L22 task 1/1bis: the agent runtime is real. `AgentRegistry` used to only enable/disable a fixed, in-memory declaration array — nothing ever executed. `@cogenta/agents` gains a real execution loop wiring (`createAgentRunner`, `agents/orchestrator.ts`) together with everything the loop needed but never had a home for: persistent, editable agent declarations (`createFileAgentDeclarationStore`), a persistent, encrypted-at-rest LLM provider store (`createFileProviderConfigStore`, AES-256-GCM keyed from `COGENTA_AUTH_SIGNING_KEY`, R7), and a "skills" instruction-text library (`createFileAgentSkillStore`, `skills/library.ts` — deliberately distinct from L7's marketplace skill registry). Three built-ins are seeded on first boot: the superagent ("Cogenta Agent", enabled by default, autonomy `propose`) and two disabled examples (a dependency-scanner agent backed by the new `deps.scan` tool, and a content-watch example). Autonomy has a new three-level UI mapping (`report-only`/`co-pilot`/`autopilot`) onto contract C's frozen `AutonomyLevel` vocabulary (`autonomy/levels.ts`) — the contract itself is unchanged.
121
+
122
+ `@cogenta/api`'s `agents-router.ts` gains real `create`/`update`/`remove`/`run` capabilities (all optional on `AgentRegistryLike`, backward compatible with a caller that only ever built a fixed `createAgentRegistry`); two new routers, `providers-router.ts` and `agent-skills-router.ts`. All three routers now correctly `decodeURIComponent` path segments — a pre-existing gap in `agents-router.ts` this lot's own end-to-end test caught (the seeded superagent's name, "Cogenta Agent", contains a space). New `ErrorCode`s (`@cogenta/core`): `AGENT_DUPLICATE`, `AGENT_DISABLED`, `AGENT_NO_PROVIDER` (501, mirrors `SITE_PLAN_NO_PROVIDER` — R2's "no provider configured" is not a failure), `AGENT_BUILTIN_UNDELETABLE`, `PROVIDER_NOT_CONFIGURED`, `AGENT_SKILL_UNKNOWN`/`AGENT_SKILL_DUPLICATE`/`AGENT_SKILL_BUILTIN_UNDELETABLE`, `AGENT_REGISTRY_READ_ONLY` (501), `AGENT_RUNTIME_UNAVAILABLE` (503, mirrors `ASSIST_UNAVAILABLE`).
123
+
124
+ **Breaking, within pre-alpha's existing minor-only convention** (see prior changesets' own note): `createAgentDelegateTool`'s tool name is no longer the fixed `agent.delegate` — it is now `agent.delegate.<slug-of-subagent-name>`, so an orchestrator offering several named sub-agents can expose each as a distinct, nameable tool instead of one ambiguous generic call. The permission stays the single, taxonomy-fixed `agent.delegate` (`tools@1.0`); no contract change.
125
+
126
+ `@cogenta/cli`'s `cogenta serve` now always constructs this runtime (three file stores under `.cogenta/agents-runtime/`) and mounts `/api/agents`, `/api/providers` and `/api/agent-skills` unconditionally — R2 still holds: without a configured provider, every route above works except `POST /api/agents/:name/run`, which refuses with `AGENT_NO_PROVIDER` before any network call (proven end to end in `packages/cli/test/serve-agents.test.ts`, including a real tool-calling loop and an R4 permission-refusal case against a local HTTP double of the Anthropic Messages API). `create-cogenta` seeds the same three built-ins at scaffold time.
127
+
128
+ The admin's "Agents" screen (`packages/admin`, private, no changeset) is genuinely editable now — create/edit/run/delete a sub-agent, per-tool permission checklist, autonomy/budget/skills/sub-agents — and gains two new screens, "Providers" and "Skills".
129
+ - c489fde: API keys gain the last two lifecycle actions fiche 20 left open: purge and
130
+ recovery from a mistaken revocation (fiche 62).
131
+
132
+ `ApiKeyStore` (`@cogenta/auth`) gains `purge(id)` — a real, permanent
133
+ `DELETE` of the key row and its usage history, refused unless the key has
134
+ been revoked for at least `MIN_PURGE_AFTER_REVOKED_DAYS` (30, newly exported)
135
+ — and `recover(id)` — mints a replacement carrying the same name, scope and
136
+ quota as a key revoked by mistake, without ever lifting that key's
137
+ `revokedAt` back to `null`. Recovery only works within
138
+ `RECOVERY_WINDOW_MS` (24h, newly exported) of the revocation; past that
139
+ window, or for a key that was never revoked, both throw the two new error
140
+ codes below. This is decision (b) from fiche 62's own recommendation: a
141
+ revoked key is usually revoked for a security reason, so recovery mints a
142
+ new credential rather than silently reactivating a possibly compromised one.
143
+
144
+ `@cogenta/core` gains two error codes: `API_KEY_PURGE_INVALID` and
145
+ `API_KEY_RECOVERY_INVALID` (both mapped to HTTP 409 — the id names something
146
+ real, refused only because of its current state).
147
+
148
+ `@cogenta/api`'s `/api/api-keys` router gains `DELETE .../purge` and
149
+ `POST .../recover`, both admin-only, following the same request/response
150
+ shape as the existing `rotate` route (the raw key appears exactly once, in
151
+ the `recover` response).
152
+
153
+ `cogenta serve` (`@cogenta/cli`) records `apikey.purge` and `apikey.recover`
154
+ in the audit log, alongside the `apikey.create`/`apikey.rotate`/
155
+ `apikey.revoke` entries that already existed — every API key lifecycle
156
+ mutation now produces a verifiable audit entry, closing the gap fiche 20
157
+ first flagged.
158
+ - 54ca689: API key lifecycle, rotation and a per-key request quota (fiche 20).
159
+
160
+ **Breaking (`@cogenta/api`):** `POST /api/api-keys` no longer mints a key that
161
+ never expires by default. A request that omits `expiresAt` now gets a
162
+ 90-day expiry — a real, generous but bounded default, since a key with no
163
+ expiry is a key that leaks forever. Pass `neverExpires: true` explicitly to
164
+ keep the old "never expires" behaviour. Any script that creates API keys
165
+ without setting `expiresAt` will see its keys start expiring after 90 days;
166
+ set `neverExpires: true` (or a longer `expiresAt`) if that is not wanted.
167
+
168
+ New, additive:
169
+
170
+ - `POST /api/api-keys/{id}/rotate` (`@cogenta/api`, `@cogenta/auth`'s
171
+ `ApiKeyStore.rotate`): mints a replacement carrying the same name, scope
172
+ and quota, and lets the original keep authenticating for a chosen grace
173
+ window (1h/24h/7d) instead of dying mid-flight. The new key's raw value is
174
+ returned exactly once, the same rule `POST /api/api-keys` already follows.
175
+ - A per-key request quota (`rateLimitPerMinute`, `@cogenta/auth`), enforced
176
+ once per request by `resolveActor` when a `RateLimitDriver` is supplied.
177
+ Exceeding it answers `429` with `Retry-After` and `RateLimit-*` headers.
178
+ `@cogenta/core` gains the `rateLimit` driver need (`createRateLimitRegistry`,
179
+ a Redis driver and an in-process one — R1: works with no Redis at all) and
180
+ a matching `rateLimit` configuration section; `cogenta serve`/`doctor` wire
181
+ and report it.
182
+ - Aggregated 7- and 30-day call counts per key (`ApiKeyStore.usage`), and a
183
+ new admin notice when a key is within seven days of expiring
184
+ (`createApiKeyExpiryNoticeSource`).
185
+ - `ApiKey` gains `rateLimitPerMinute` and `supersededBy` (set once a key has
186
+ been rotated). `ApiKeyStore` gains `getById`, `rotate` and `usage`.
187
+
188
+ New error codes: `API_KEY_RATE_LIMITED` (429), `API_KEY_ROTATION_INVALID`
189
+ (409 — a revoked or expired key cannot be rotated), `RATE_LIMIT_FAILED`.
190
+
191
+ The property that a raw API key is shown exactly once, never twice, holds
192
+ for the new rotate response too: `listApiKeys` and the `previous` half of a
193
+ rotation response never carry key material.
194
+ - 23299e9: The assistant's vector index is now explained and manageable, not just a raw
195
+ count (L22 task 4).
196
+
197
+ - `GET /api/assistant` now reports, per content collection, whether it is
198
+ included in the index and how many chunks it contributes
199
+ (`vector.collections`), plus the reserved pseudo-collection name reference
200
+ documents are stored under (`vector.referenceCollection`).
201
+ - A new site setting, `assistant.indexedCollections` (`GET|PATCH
202
+ /api/settings`, `admin` only), lets an operator exclude a collection —
203
+ published articles included — from the index. The change is read live: it
204
+ applies on the next content save, with no restart, and the existing
205
+ "Reindex vectors" tool applies it to already-indexed content.
206
+ - A document upload flow — `GET/POST /api/assistant/documents` and `DELETE
207
+ /api/assistant/documents/:id` — lets an admin add reference material (PDF,
208
+ DOCX, Markdown, plain text) to the same index the site's own content feeds,
209
+ reusing the existing `document.extract_text` → `chunkDocument` →
210
+ `EmbeddingProvider.embed` pipeline rather than a second one. Each document
211
+ tracks its own `pending`/`indexed`/`error` state.
212
+ - `@cogenta/agents` gains `createReferenceDocumentStore`,
213
+ `ingestReferenceDocument`/`removeReferenceDocumentVectors`, and the
214
+ `REFERENCE_DOCUMENT_COLLECTION`/`REFERENCE_DOCUMENT_LOCALE`/`REFERENCE_DOCUMENT_STATUS`
215
+ constants a caller needs to retrieve them (e.g. via `assist.chat`'s
216
+ `collections` input).
217
+ - `@cogenta/core` gains one error code, `ASSIST_DOCUMENT_NOT_FOUND` (404).
218
+
219
+ All of this is additive and degrades the same way the rest of L18 does: a
220
+ site with no embeddings provider gets none of it, and every other feature
221
+ works unchanged (R2).
222
+ - 0692713: Fiche 30 — agents and assistant admin:
223
+
224
+ - `@cogenta/core`: adds a resolved `assistant.monthlyTokenLimit` config section (default one million tokens a month) and a new `ASSIST_BUDGET_EXCEEDED` error code.
225
+ - `@cogenta/agents`: adds `createAssistUsageTracker`, a per-tool, calendar-bucketed token/call counter for the writing assistant (distinct from the existing per-agent `BudgetTracker`), wired into `createAssistToolset` and `createAssistRuntime` (`AssistRuntimeOptions.onUsage`, `AssistRequest.tool`). `AssistToolset` gains optional `model` and `usage` fields.
226
+ - `@cogenta/api`: `GET /api/assistant` now reports `model`, `usage` (when a tracker is configured) and `vector` (driver/dimensions/count/lastIndexedAt, when a vector store exists). `POST /api/assistant/run` refuses with `ASSIST_BUDGET_EXCEEDED` (429) once the monthly cap is reached, before the provider is called. `createAssistantRouter` gains an optional `vectorInfo` option.
227
+ - `@cogenta/cli`: `AssistantAssembly` gains `vectorInfo` (vector index visibility) and wires a usage tracker into the assistant toolset from `config.assistant.monthlyTokenLimit`. `withVectorIndexing` gains an optional `onIndexed` callback. `recordContentAudit` now records an accepted assistant suggestion's `field`/`tool` (sent by the admin as `assistApplied` on a content save) distinctly in the audit diff, alongside contract A's existing `provenance`/`provenanceDetail`.
228
+
229
+ All additive — a site with no `assistant` config section gets the same default cap as before, and a site with no AI provider sees no `usage`/`model`/`vector` fields at all.
230
+ - 36744d3: Fiche 21: the audit log gains what the state-of-the-art comparison named as
231
+ missing — a real entry detail, filters that reach a date range, an export,
232
+ an actually-scheduled integrity check, and a way to tell a human's action
233
+ from an agent's.
234
+
235
+ **Task 1 — detail.** `GET /api/audit/{id}` (`@cogenta/api`'s `audit-router.ts`)
236
+ answers with the entry, its resolved actor kind and label (an email, or an
237
+ API key's name), and — for a `content.create`/`update`/`restore` action — the
238
+ same structural diff `GET /{collection}/{id}/diff` already computes, called
239
+ through rather than recomputed (the fiche's own warning against duplicating
240
+ it). This needed a place to keep which content version an action produced:
241
+ `RecordAuditInput`/`AuditEntry` gain `version`, stored in a new nullable
242
+ `cogenta_audit_log.version` column added with a `try`/`catch` `alter table`
243
+ (no portable `add column if not exists` across SQLite/Postgres/MySQL) — and
244
+ **deliberately excluded from the hash `computeHash` chains together**. Adding
245
+ a field to that canonical list would change what every already-recorded hash
246
+ means, and every site's existing chain would fail `verify()` the moment this
247
+ code ran. The fields that matter for accountability — who, when, what
248
+ action, on what — are untouched; `version` is UI-convenience metadata, not
249
+ inside the tamper-evidence boundary. A permission refusal on the diff's own
250
+ collection (an admin who was never granted an authoring role there) degrades
251
+ to `diffUnavailable`, not a 403 for the whole entry.
252
+
253
+ **Task 2 — dates, export, pagination.** `since`/`until`/`actorKind` filters
254
+ on `GET /api/audit`, and `GET /api/audit/export?format=csv|json` (bounded to
255
+ 10,000 entries) for the filtered view. The export is itself an audit-worthy
256
+ event — a personal-data extraction, per the fiche — recorded as
257
+ `audit.export` (format and count only, never the exported rows) at the same
258
+ transport-boundary layer `cogenta serve` already records every other
259
+ mutation at.
260
+
261
+ **Task 3 — scheduled integrity, for real.** `@cogenta/auth` gains
262
+ `AuditLog.verifyRange`/`get` (a bounded, checkpoint-resuming form of
263
+ `verify()`) and `createAuditIntegrityStore`, which persists the last
264
+ check's outcome across a restart. `cogenta serve` runs it once at startup
265
+ and then on its own `setInterval` (daily by default,
266
+ `ServeOptions.auditIntegrityTickMs` overridable for tests) — the same
267
+ accepted trade-off as the scheduled-publication tick. Most runs are
268
+ incremental (only entries after the last checkpoint); a full replay runs
269
+ weekly on its own as the backstop the fiche asks for, since an incremental
270
+ check cannot see tampering in already-checkpointed history. A break sends
271
+ one signed channel alert (`security.audit_integrity_broken`, only on the run
272
+ that first finds it — never once per tick) and a non-dismissible, danger-
273
+ severity admin notice that clears itself once a forced full check reports
274
+ the chain intact again. `GET`/`POST /api/audit/integrity` expose the status
275
+ and the "verify now" that persists its result, alongside the untouched,
276
+ stateless `GET /api/audit/verify`.
277
+
278
+ **Task 4 — distinguishing actors.** `classifyAuditActor` (`@cogenta/auth`)
279
+ reads signals the log already carried — `actorId === null` is `system`, the
280
+ `apikey:` prefix `resolveActor` has minted since L13 is `api_key`, the
281
+ `agent.tool.` prefix `withAudit` has minted since L4 is `agent`, everything
282
+ else is `human` — no schema change needed. `withAudit` (`@cogenta/agents`)
283
+ gains optional `model`/`autonomyLevel`, carried into the recorded diff when
284
+ a caller tracks them. `?actorKind=` filters `GET /api/audit`.
285
+
286
+ **Task 5 — retention, honestly.** No purge is wired into a schedule in this
287
+ pass — `AuditLog.prune(olderThan)` exists, tested, and safe (it refuses to
288
+ purge a segment that does not itself verify first, and records a genesis
289
+ anchor so the surviving chain keeps verifying from a documented truncation
290
+ point rather than silently going quiet about it), but nothing calls it
291
+ automatically yet. The admin screen says so plainly: this journal keeps
292
+ every entry and grows without limit until an operator acts.
293
+
294
+ None of this is a breaking change: `AuditLog.verify()`'s signature and every
295
+ existing route's response shape are unchanged, and the new column/tables
296
+ are additive (a fresh `ensureAuthTables` run tolerates them being already
297
+ there, an existing install picks them up the same way).
298
+ - af57fa2: L22 task 2: the inbound side of `@cogenta/channels` (L6) is wired for real. Until now, `@cogenta/channels`' identity-linking protocol, command router, and Telegram/Slack/Discord adapters were built and tested but never connected to anything — only outbound notice delivery was live.
299
+
300
+ `@cogenta/channels` gains a plug-in point `createCommandRouter`'s own header comment named as explicitly out of scope for L6: `CommandRouterOptions.chat`. A message that matches no *registered* command name now falls through to this optional handler instead of `{kind: 'unrecognized'}` — same `authorizeInboundCommand` gate as a named command, evaluated against `chat.requiredRoles`. `createAgentChatBridge` builds the handler itself: it resolves an optional `"@Agent Name: message"` mention (falling back to a configured default agent, with a warning, on an unknown name), calls a structural `AgentRunnerLike.run(name, instruction, trigger?)` — deliberately the same three-argument shape `@cogenta/api`'s `agents-router.ts` already declares, not `@cogenta/agents`' raw options-object `AgentRunner['run']` — and replies with the result, truncated and flattened to fit the existing `NotificationChannelMessage` budget (`REPORT_SCREEN_BUDGET_CHARS`) rather than stretching an ill-fitting type. The one rule this whole module exists to keep: a linked channel identity is authorized against the *Cogenta account's own* roles before the runner is ever called, and defaults to requiring `admin` — the same role `POST /api/agents/:name/run` itself requires, so a channel can never grant more access than the linked account's own standing already would over HTTP (R4). Zero changes to any provider adapter (`telegram`/`slack`/`discord` `inbound.ts`) were needed — all three already call `router.route()` uniformly for every linked-identity message.
301
+
302
+ `@cogenta/cli` gains a new, separate command: `cogenta channels`. Telegram long-polling is safe per replica only with exactly one dedicated process; Slack Socket Mode and Discord Gateway are each inherently a single persistent connection. None of the three ever start inside `cogenta serve` — this command is a standalone, optional, single-instance process (built the same way `cogenta mcp` is: a second independent entry point onto the same database and the same `.cogenta/agents-runtime` agent declarations, never a second copy of either) whose only job is connecting configured channels and routing authorized chat messages to a real agent run. Bot credentials are read from the environment only (`COGENTA_CHANNELS_TELEGRAM_BOT_TOKEN`, `COGENTA_CHANNELS_SLACK_BOT_TOKEN`/`COGENTA_CHANNELS_SLACK_APP_TOKEN`, `COGENTA_CHANNELS_DISCORD_BOT_TOKEN`) and never written to `cogenta.config.mjs` (R7); a provider with no token configured is simply not started (R1). `buildAgentRuntime`'s options gain an injectable `approvalQueue` (defaulting to a fresh in-memory one, unchanged behaviour for every existing caller), and `AgentRuntimeAssembly` now exposes the live instance it actually uses — every side-effecting core tool (`content.write_draft` included) is `reversible: false`, so `with-autonomy.ts`'s `forcedApproval` always routes it through this queue regardless of autonomy level; exposing it is what let this lot's own end-to-end test (`packages/cli/test/channels-chat.test.ts`) prove a chat message drives the *correct* contract-C tool through to a real approved, created entry, rather than only proving a tool was proposed.
303
+
304
+ The admin (`@cogenta/admin`, private, no changeset) gains a "Canaux" screen in the IA section (any signed-in role, since linking is personal and used for notices too) — reusing the exact `/api/notices/channels/*` linking endpoints fiche 38 already exposed, no new linking mechanism — and extends the existing "MCP" screen with a "Chat API" key purpose: generates an `admin`-scoped API key (the same mechanism `/api/api-keys` already provides) and documents the `POST /api/agents/:name/run` request/response format, the same single-call-per-turn shape the admin's own new floating chat widget (bottom-right, on every authenticated screen) uses via the existing `runAgent` client function — no second streaming protocol.
305
+
306
+ `@cogenta/core` gains one `ErrorCode`: `CHANNEL_PROVIDER_NOT_CONFIGURED`, thrown by `cogenta channels` when a provider's required environment variables are absent (caught internally and logged as "skipped", never surfaced as a failure — R1).
307
+
308
+ Left honestly open: the approval queue `cogenta channels` (and any future admin approvals screen) would decide a pending write against has no REST surface yet — a real, pre-existing gap this lot's own test works around directly rather than papering over, not something to fix here.
309
+ - 322d1a3: Fiche 52 — Cogenta Commerce: orders and customers, the trailing gap this
310
+ audit's own opening line named ("le modèle de commande n'a aucune adresse
311
+ postale structurée"). **Contains a breaking change**, called out below —
312
+ contract E is deliberately not yet frozen (ADR-0024), so this ships as
313
+ `minor` rather than `major` per this project's existing pre-alpha practice
314
+ (see the taxonomies/trash changesets), with the break stated plainly here.
315
+
316
+ **Breaking**: `POST /api/commerce/payments/{id}/refund` now requires a
317
+ non-empty `reason` in the request body ("motif obligatoire" — task 6) and
318
+ answers `{ refund, creditNote }` instead of the bare refund record. A caller
319
+ sending no reason now gets `400 COMMERCE_AMOUNT_INVALID` instead of a
320
+ refund with no stated cause.
321
+
322
+ `@cogenta/commerce`:
323
+ - `Order` gains six structured shipping-address fields
324
+ (`shippingAddressLine1/2`, `shippingCity`, `shippingPostalCode`,
325
+ `shippingRecipient`, `shippingPhone`) and four shipment-tracking fields
326
+ (`trackingCarrier`, `trackingNumber`, `trackingUrl`, `shippedAt`) — all
327
+ nullable, added in place to `cogenta_commerce_orders` on an
328
+ already-deployed site (idempotent `alter table`, same idiom as
329
+ `menu-tables.ts`'s `location` column; no down path exists or is needed for
330
+ an additive nullable column).
331
+ - `OrderStore` gains `placeManual` (a shopkeeper-entered order — phone,
332
+ trade-show, correction — that opens a real cart and calls `place()`
333
+ internally, never a second placement path), `update` (corrects the e-mail
334
+ and/or address while `pending`; refuses with `COMMERCE_ORDER_LOCKED` once
335
+ paid) and `setTracking` (attaches carrier/number/url; moving a `paid`
336
+ order to `shipped` is a side effect of attaching tracking, not a separate
337
+ step). `OrderListOptions` gains `placedFrom`/`placedTo`.
338
+ - New module `order/notify.ts`: `createOrderEmailQueue`, a persisted,
339
+ retried (`MAX_ATTEMPTS = 5`) transactional e-mail queue built on
340
+ `@cogenta/channels`'s existing `createEmailAdapter` — never a second
341
+ transport. A new direct dependency on `@cogenta/channels` follows (R9:
342
+ reuse over reinvention, same package this project already depends on
343
+ elsewhere).
344
+ - New module `order/csv.ts`: `ordersToCsv`, an RFC 4180 accounting export,
345
+ zero dependency (R9) — one row per order (reference, date, status, email,
346
+ the four summed figures, invoice number when one exists). Decision this
347
+ fiche had to make and is documenting here: row-per-order rather than
348
+ row-per-line, matching the fiche's own singular "export comptable" wording.
349
+ - `CustomerStore` gains `anonymize` (GDPR erasure of the customer record —
350
+ email/name only; an order's own historical copy of the email is
351
+ deliberately retained as a financial record).
352
+ - New module `invoice/credit-note.ts`: `createCreditNoteStore`, one credit
353
+ note per refund (its own `CN-2026` series, sharing the same
354
+ compare-and-set sequence table as invoices via the newly extracted
355
+ `invoice/sequence.ts`) — issued automatically by the refund route once
356
+ billing is configured, never a second manual step.
357
+ - `CommerceAdminRouter` gains routes: `POST/GET/PATCH /orders`,
358
+ `PUT /orders/{id}/tracking`, `GET /orders/{id}/emails`,
359
+ `GET /orders/{id}/credit-notes`, `GET /orders/export.csv`,
360
+ `GET /payments/{id}/refunds`, `GET/POST /customers/{id}`,
361
+ `POST /customers/{id}/export`, `POST /customers/{id}/anonymize`.
362
+ `CommerceResponse.body` can now also be a plain `string` (the CSV export),
363
+ alongside the existing JSON/`Uint8Array` shapes.
364
+ - `@cogenta/core` gains four error codes: `COMMERCE_CUSTOMER_NOT_FOUND`,
365
+ `COMMERCE_ORDER_LOCKED`, `COMMERCE_TRACKING_INVALID`,
366
+ `COMMERCE_CREDIT_NOTE_NOT_FOUND`.
367
+
368
+ `@cogenta/cli` wires all of the above into `cogenta serve`: the order-email
369
+ queue (built whenever an e-mail transport is configured — always, in
370
+ practice, since `runServe` builds the degraded `FileEmailTransport`
371
+ unconditionally) and the credit-note store (built whenever `billing` is
372
+ configured, the same gate invoicing already uses) are passed to
373
+ `createCommerceAdminRouter`; a new scheduled task, `commerce-order-emails`
374
+ (`COMMERCE_EMAIL_TICK_MS = 60_000`, overridable via `commerceEmailTickMs`
375
+ for tests), flushes the retry queue — and is correctly folded into the
376
+ scheduler's own heartbeat interval calculation, a real bug this fiche found
377
+ and fixed (the heartbeat previously only ran as often as the *slowest* of
378
+ the other seven tasks needed, so a fast test override on this one alone
379
+ would never actually fire). The transport layer gains a `text/csv` branch
380
+ alongside the existing JSON/PDF ones.
381
+ - 0ca8a79: Add optimistic concurrency detection and per-field error naming for the entry editor (fiche 02, tasks 3 and 7).
382
+
383
+ - `@cogenta/core` gains the `CONTENT_STALE_WRITE` error code.
384
+ - `@cogenta/schema`'s `UpdateInput` gains an optional `expectedUpdatedAt`. When a caller
385
+ passes it, `update()` compares it against the live row's `updatedAt` and refuses with
386
+ `CONTENT_STALE_WRITE` (409) if someone else's write landed first, instead of silently
387
+ overwriting it. Omitting it keeps the previous last-write-wins behaviour unchanged.
388
+ - `@cogenta/api`'s `PATCH` body accepts the new `expectedUpdatedAt`, and `errorResponse`
389
+ now includes `error.field` for `CONTENT_INVALID`/`CONTENT_SLUG_INVALID` refusals, naming
390
+ the schema-declared field the error is about so a client can drive per-field validation
391
+ UI without parsing the message.
392
+
393
+ Both additions are additive and backward compatible: existing callers that never send
394
+ `expectedUpdatedAt` see no behaviour change, and `error.field` is only ever present for
395
+ the two codes listed above.
396
+ - c392e24: Redirects: 404 log, prefix patterns, editing, CSV import/export, automatic
397
+ redirect on slug rename, and 307/308/410 status codes (fiche 12).
398
+
399
+ **`@cogenta/core`**: gains a `notFoundLog` config section (`enabled`,
400
+ `maxPaths`, `retainDays`) — on by default, bounded, purged past its
401
+ retention. Never stores an IP address or a user agent.
402
+
403
+ **`@cogenta/schema`**:
404
+ - `RedirectStatus` widens from `301 | 302` to `301 | 302 | 307 | 308 | 410`.
405
+ A 410 (Gone) row needs no `to`. Consumers that exhaustively switch on
406
+ `RedirectStatus` — a rare pattern, but a real one — need a case for the
407
+ three new values.
408
+ - `RedirectStore` gains `update(from, { to?, status? })` — implementors of
409
+ the interface (not typical callers) must add it. `RedirectStore.add`'s
410
+ `to` is now optional, required only when `status` is not 410.
411
+ - New: `createNotFoundLogStore`/`NotFoundLogStore` (the 404 log — aggregated
412
+ by path, capped at `maxPaths` distinct paths, no personal data ever) and
413
+ `createRedirectPatternStore`/`RedirectPatternStore` (prefix redirects —
414
+ `/blog/*` to `/actualites/*` — matched by `startsWith`, never a regular
415
+ expression, so the public routing path can never be exposed to
416
+ catastrophic backtracking).
417
+ - New: `withRedirectTracking` — wraps a `ContentStore` so renaming the slug
418
+ of a **published** entry writes a 301 from the old path to the new one on
419
+ its own, reversibly (renaming back makes the redirect disappear), and a
420
+ chain of renames stays flattened to one hop.
421
+
422
+ **`@cogenta/api`**: `redirect-router.ts` gains `PATCH /api/redirects` (edit
423
+ in place), `?q=`/`?limit=`/`?offset=` on the list, `/api/redirects/patterns`
424
+ (prefix redirects), and `/api/redirects/export` / `/api/redirects/import`
425
+ (CSV, always previewed before anything is written — pass `apply: true` to
426
+ commit). New `createNotFoundRouter` (`GET`/`DELETE /api/not-found`). New
427
+ `parseCsv`/`stringifyCsv` — hand-written, zero dependency (R9).
428
+
429
+ **`@cogenta/cli`**: `cogenta serve` mounts `/api/not-found` and the new
430
+ `/api/redirects/*` routes, applies prefix-redirect resolution after the
431
+ exact-match table finds nothing, answers a 410 with no `Location` header,
432
+ records every public GET that matches no route into the 404 log (never for
433
+ `/api/*`), and purges the log past its retention on a daily tick (new
434
+ `ServeOptions.notFoundPurgeTickMs` overrides it, for tests). Renaming the
435
+ slug of a published entry now writes its redirect automatically, wired
436
+ through `withRedirectTracking`.
437
+ - 562c9c1: Add the "Apparence" admin screen (fiche 14) — the CMS's most-differentiating
438
+ feature, AI skin generation, was previously exposed only through the CLI.
439
+
440
+ - `@cogenta/render` gains `mergeSkinTokens` (`SkinTokenOverrides`): overlays a
441
+ partial token tree onto a complete base skin, group by group, key by key.
442
+ - `@cogenta/schema` gains `createThemeStore`/`ensureThemeTable` — one row of
443
+ theme overrides (a partial token overlay, additional CSS, and four identity
444
+ media references), the database half of the two-source-of-truth design
445
+ task 0 settles on: `theme.tokens.json` stays the versioned file default,
446
+ the database holds what an `admin` changed from the admin screen.
447
+ - `@cogenta/plugins`'s `SkinGalleryEntry` now carries the accepted skin's real
448
+ `tokens` (`null` for a rejected entry) — needed to render a swatch or apply
449
+ a gallery skin, previously only metadata.
450
+ - `@cogenta/api` gains `createThemeRouter` (`GET/PUT/DELETE /api/theme[/overrides]`,
451
+ `GET /api/theme/skins`, `POST /api/theme/skins/:id/apply`,
452
+ `POST /api/theme/generate`, `POST /api/theme/export`), plus the
453
+ `SKIN_*`/`THEME_*` error-code → HTTP-status mappings it needs.
454
+ - `@cogenta/cli` wires it all into `cogenta serve`/`dev`: `resolveStyles()`
455
+ recomputes the served stylesheet on every request (file tokens merged with
456
+ saved overrides plus additional CSS), which is what makes a saved change
457
+ visible on the very next page view instead of only after a restart — the
458
+ "hot swap" contract D already promised for the file alone. A new
459
+ `POST /api/theme/preview` route renders the real home page with a candidate
460
+ overlay nobody has saved yet, the same iframe-on-the-real-render decision
461
+ L16 made for the page builder. Exporting the merged tokens back into
462
+ `theme.tokens.json` is gated to `cogenta dev` only, mirroring the
463
+ ADR-0010 rule L19's site-plan applier already uses for the schema file.
464
+
465
+ R2 verified: without an LLM provider, `GET /api/theme` reports
466
+ `aiAvailable: false` and the admin's AI section does not render at all — no
467
+ error, no dead link. R6 verified: an AI-generated candidate or a chosen
468
+ gallery skin is never applied automatically; a save is always a separate,
469
+ explicit action.
470
+ - edf5623: Fiche 15 — comments (ADR-0025, new contract F, `comments@1.0`):
471
+
472
+ - **New package `@cogenta/comments`**: the comment model and store
473
+ (`CommentStore`) — plain-text body only (R3: no HTML tags accepted, ever),
474
+ hashed IP (never stored in clear, RGPD), moderation status
475
+ (`pending`/`approved`/`spam`/`trash`), threading via `parentId`,
476
+ `provenance`. A reversible migration (`ensureCommentsTables`/
477
+ `dropCommentsTables`), tested up/down/up on SQLite; Postgres/MySQL/MariaDB
478
+ integration tests are written (`test/integration/tables.test.ts`) but not
479
+ executed this session (no local Docker). `createCommentsRouter` is the
480
+ CMS's first public write route (`POST /api/comments`, no actor required)
481
+ plus the admin moderation queue, both behind contract F's own permission
482
+ vocabulary (`comments.read`/`moderate`/`reply`/`purge`/`settings`, distinct
483
+ from contract A's five frozen actions). The public route enforces, from
484
+ day one: rate limiting by IP and by target (`createCommentRateLimiter`),
485
+ a honeypot field, a minimum fill-delay, non-AI spam heuristics
486
+ (`checkSpamHeuristics`), and the WordPress "auto-approve a returning
487
+ commenter" rule. A no-JS `<form method=post>` gets a `303` redirect back to
488
+ its own page (`redirectTo`, validated against open-redirect and HTTP
489
+ response-splitting) instead of a raw JSON body.
490
+ - **`@cogenta/core`**: ten new error codes (`COMMENT_NOT_FOUND`,
491
+ `COMMENT_BODY_INVALID`, `COMMENT_AUTHOR_INVALID`, `COMMENT_TARGET_INVALID`,
492
+ `COMMENT_TARGET_CLOSED`, `COMMENT_PARENT_INVALID`,
493
+ `COMMENT_PARENT_TOO_DEEP`, `COMMENT_STATUS_INVALID`,
494
+ `COMMENT_RATE_LIMITED`, `COMMENT_SPAM_DETECTED`).
495
+ - **`@cogenta/schema`**: `SITE_SETTINGS_REGISTRY` gains the `discussion`
496
+ group (`discussion.enabled`/`moderationRequired`/`allowAnonymous`/
497
+ `autoCloseDays`/`maxNestingDepth`/`notifyEmail`) — the site-wide defaults
498
+ a collection or an entry can still override from `@cogenta/comments`'s own
499
+ settings store (per-collection/per-entry overrides deliberately do not
500
+ live in this registry, which is site/locale scoped only).
501
+ - **`@cogenta/api`**: `shell-status-router.ts` gains `commentsPending` (a
502
+ structural `CommentsQueueLike`, the same pattern `commerceOrdersPending`
503
+ already uses) — additive, existing callers that never pass `comments` see
504
+ `null` exactly as before.
505
+ - **`@cogenta/theme-canonical`**: `renderCommentsSection` — the comment
506
+ thread and its plain-HTML submission form, built through the existing
507
+ `h()`/`text()` tree (no `raw()` escape hatch exists in this package, which
508
+ is what makes "no visitor HTML ever reaches the page" structural rather
509
+ than a habit to remember). Rendered by `renderEntryPage`
510
+ (`@cogenta/cli`'s `theme-render.ts`) after the page's own `<main>`, on both
511
+ the published page and the L16 page-builder preview's own draft render —
512
+ except the preview, which never shows it (its `_ts` anti-spam field cannot
513
+ be identical across two separate renders, so byte-identity there would be
514
+ comparing two different legitimate values; `serve-builder.test.ts`'s
515
+ fidelity test now documents this as a deliberate, checked difference).
516
+ Contract B is untouched — no `comments` block, same reasoning L10 gave for
517
+ `/search`.
518
+ - **`@cogenta/import`**: `importWordPress` gains an optional `comments`
519
+ option (a `CommentStore`) — when given, every importable WordPress comment
520
+ is written with its real status (`wp:comment_approved` mapped to
521
+ pending/approved/spam/trash, not just `'1'`), real threading
522
+ (`wp:comment_parent`), on **both** posts and pages. Pages never imported a
523
+ single comment before this — a real, independent bug, not something this
524
+ fiche introduced, found while checking what the importer does today per
525
+ the fiche's own instruction. Inline HTML a legacy WordPress comment form
526
+ allowed (`<a>`, `<em>`, …) is stripped to plain text and reported (R3: no
527
+ escape hatch). Absent `comments` keeps the pre-fiche-15 behaviour
528
+ unchanged (approved-only, posts-only, the synthetic `comment` collection)
529
+ for a caller that has not wired `@cogenta/comments` yet — its `post` field
530
+ is a hard `relation` to the `post` collection specifically, so extending
531
+ it to pages was never an option, only the real store is.
532
+ - **`@cogenta/cli`**: `cogenta serve` mounts `/api/comments` (public POST +
533
+ moderation queue), extends `readBody` to also parse
534
+ `application/x-www-form-urlencoded` (the no-JS form's own content type —
535
+ every other route still only ever sends JSON), wires the comment thread
536
+ into `theme-render.ts`'s page render, and passes a real `CommentStore`
537
+ into every `importWordPress` call site (the terminal command and the
538
+ admin's import screen alike). `cogenta doctor`/`serve` create contract F's
539
+ tables idempotently, the same way commerce's tables are created — a site
540
+ that never receives a comment never pays for them.
541
+
542
+ Admin (`@cogenta/admin`, private, no changeset): a moderation queue screen
543
+ (`/comments`, counters, bulk actions, search, reply-from-the-admin), a
544
+ pending-count nav badge, `assist.moderate` reused verbatim as an indicator
545
+ (never an action — its own closed `none`/`review` union already guarantees
546
+ that, per the fiche's own instruction not to build a second decision path),
547
+ a "Discussion" settings tab (previously a placeholder), and a per-entry
548
+ comments toggle in the entry editor sidebar.
549
+ - db307e0: Add form definitions and submissions — contract G (`forms@1.0`, ADR-0026, fiche 16). A site can now build a form in the admin and receive real submissions, without JavaScript and without an AI provider.
550
+
551
+ - New package **`@cogenta/forms`**: `FormDefinition`/`FormSubmission` model (nine field kinds — text, longText, email, phone, number, date, choiceSingle, choiceMulti, consent; no `file` field in this first version, a deliberate scope cut), `createFormStore` (definitions CRUD, `submit`/`list`/`markStatus`/`bulkMarkStatus`/`searchByEmail`/`deleteByEmail`/`purgeExpired`), full server-side `validateSubmission` (independent of any client-side check, for every field kind), anti-abuse primitives (`checkHoneypot`, `checkFillDelay`, `checkSubmitRateLimit`), and `notifyNewSubmission`/`sendAutoresponder` — both built on `@cogenta/channels`'s existing email adapter, never a second transport. `ensureFormsTables` follows the same `create table if not exists` shape as `@cogenta/commerce`'s tables — a site that never builds a form still creates them, since (unlike commerce) forms tables are cheap enough not to gate.
552
+ - `@cogenta/core` gains eleven `FORM_*` error codes.
553
+ - `@cogenta/api` gains `createFormsRouter` (`/api/forms/*`): admin-only CRUD on definitions and submissions (bulk mark/delete, unread count, CSV-ready listing, GDPR search/erase by e-mail), plus the CMS's **second public write route**, `POST /api/forms/{name}/submit` — no actor check, its own defences (honeypot, minimum fill delay, per-IP rate limit, full server-side validation) stand in for one. The client's IP is read from the resolved request context, never from a client-supplied `X-Forwarded-For` header — trusting that header would let an attacker rotate it per request and step around the rate limiter entirely. `ShellStatus` gains `formSubmissionsUnread` for the admin's nav badge (additive).
554
+ - `@cogenta/cli` wires it all into `cogenta serve`: `GET /forms/{name}` is the public, no-JavaScript "route dédiée" ADR-0026 chose over a contract B block (a bloc `form` RFC is left open in parallel); a plain HTML form post is answered with a real redirect on success or an accessible re-display of the visitor's own values and per-field error (`aria-invalid`/`aria-describedby`) on failure; notifications reuse the same `FileEmailTransport` already built for account invitations; submissions past a form's own `retainDays` are purged automatically on a daily tick, the same `retainDays`/`purgeExpired` model ADR-0022 established for the trash.
555
+ - Admin (`@cogenta/admin`, private, no changeset): `routes/forms.tsx` (the builder, reusing fiche 03's `RepeaterField` for the field list rather than a second repeater) and `routes/form-submissions.tsx` (list/filter/detail/bulk actions/CSV export via `lib/csv.ts`/GDPR search & erase by e-mail), with an unread-count nav badge.
556
+ - 49815b9: Account lifecycle: invitation by email, search/pagination/bulk actions, a
557
+ self-service public profile, dormant/MFA-recommended signals, and
558
+ irreversible anonymization (fiche 17).
559
+
560
+ **Breaking (`@cogenta/auth`), in the same pre-1.0 sense the taxonomies/trash
561
+ and redirects changesets already used this bump for**: `User['status']`
562
+ widens from `'active' | 'disabled'` to also include `'invited'` and
563
+ `'anonymized'` — an exhaustive `switch` on the old two-value union needs a
564
+ new case. `User` also gains four new non-optional fields (`displayName`,
565
+ `avatarMediaId`, `bio`, `locale`, all `string | null`) — code that builds a
566
+ `User` object literal by hand (rather than reading one back from
567
+ `UserStore`) needs to add them. `CreateUserInput` gains an optional `status`
568
+ (defaults to `active`, so existing callers are unaffected).
569
+
570
+ **`@cogenta/auth`**:
571
+ - `UserStore` gains `updateProfile` (self-service, fiche 17 task 3),
572
+ `delete` (real hard delete — safe only for a never-accepted `invited`
573
+ account, see its doc comment for why that does not contradict "accounts
574
+ are disabled, never removed"), and `anonymize` (RGPD-erasure: replaces the
575
+ email with a non-reversible `@anonymized.invalid` token, clears the
576
+ profile fields, sets `status: 'anonymized'`).
577
+ - `SessionStore` gains `lastSeenByUser()` — the last activity timestamp for
578
+ every account in one query, across every session ever held (revoked and
579
+ expired included), for the "last sign-in" column and the dormant-account
580
+ signal.
581
+ - `PasswordResetStore` gains `pending(userId)` — the still-usable token for
582
+ a user, if any, without ever returning the token itself. Used by fiche
583
+ 17's invitation to answer "invitation sent on …" and to support resend.
584
+ - New table columns on `cogenta_users` (`display_name`, `avatar_media_id`,
585
+ `bio`, `locale`), added the same additive, catch-and-ignore way the API
586
+ key lifecycle columns were.
587
+ - New error codes: `AUTH_INVITE_UNAVAILABLE` (503), `AUTH_INVITE_INVALID_STATE`
588
+ (409), `AUTH_ACCOUNT_ANONYMIZED` (409), `AUTH_ANONYMIZE_CONFIRMATION_MISMATCH`
589
+ (400).
590
+
591
+ **`@cogenta/api`**: `users-router.ts` grows substantially, entirely additive
592
+ at the route level —
593
+ - `POST /api/users` accepts `invite: true`. With `onInvite` wired, it
594
+ creates an `invited` account and hands the invitation token to the
595
+ callback instead of returning a password — the same single-use token
596
+ primitive `/forgot-password` already uses, reused rather than
597
+ reimplemented. Without `onInvite` wired (or the flag omitted), the route
598
+ behaves exactly as it always has: a generated password, shown once (R1's
599
+ mandatory fallback). The response gains `invited`/`emailSent` alongside
600
+ the (now optional) `password`.
601
+ - `GET /api/users` gains `?sort=`, `?after=`, `?limit=`, and a substring
602
+ match on display name as well as email for `?q=`. The response gains
603
+ `page: { hasMore, nextCursor }` and `meta: { invitationEmailAvailable }`
604
+ — `data` is unchanged.
605
+ - `POST /api/users/{id}/invite` (resend) and `DELETE .../invite` (cancel —
606
+ a real delete, safe for the reason above) are new.
607
+ - `POST /api/users/bulk` (`disable`/`enable`/`setRoles` over several ids at
608
+ once, `Promise.allSettled`, a report naming every failure) is new.
609
+ - `PATCH /api/users/me/profile` (self-only, mirrors the existing
610
+ self-only `/me/password`) is new.
611
+ - `POST /api/users/{id}/anonymize` (admin-only, confirmed by typing the
612
+ account's current email, refuses the last active admin the same way
613
+ disabling one already did, writes one `user.anonymize` audit entry that
614
+ never carries the erased address) is new.
615
+ - `auth-router.ts`'s `POST /api/auth/reset-password` gains one line: an
616
+ `invited` account is flipped to `active` the moment its token is
617
+ redeemed — the only place in the product that changes that bit, and the
618
+ reason the invitation never needed a second token type.
619
+ - `statusFor()` gains the four new codes above.
620
+
621
+ **`@cogenta/cli`**: `cogenta serve` wires the users router's `collections`
622
+ (for the MFA-recommended signal) and a new `onInvite` callback, delivered
623
+ through a new `invite-mail.ts` (the file-transport email, sibling to the
624
+ existing `reset-mail.ts`) pointed at the same `/admin/reset-password` screen
625
+ `onForgotPassword` already uses — accepting an invitation and resetting a
626
+ forgotten password redeem the identical token type.
627
+
628
+ Tests: `@cogenta/auth` 189 (19 new), `@cogenta/api` 582 (78 new across
629
+ `users-router.test.ts` and `auth-router.test.ts`), `@cogenta/cli` 236 (11
630
+ new in `test/serve-users.test.ts`, end to end over real HTTP against a real
631
+ mail directory — invite, read the mail, redeem, sign in; single-use and
632
+ expiry; resend/cancel; bulk actions; self-service profile; anonymization
633
+ with audit-log coherence). `@cogenta/admin` (private, no changeset) gains
634
+ 26 new UI tests across `test/users/users.test.tsx` and
635
+ `test/users/profile.test.tsx`.
636
+ - 122da7a: Fiche 18 (profile and authentication): TOTP recovery codes, readable sessions
637
+ with bulk sign-out, an account's own activity feed, and a fetchable password
638
+ policy.
639
+
640
+ **`@cogenta/core`** gains two error codes: `AUTH_RECOVERY_CODE_INVALID` and
641
+ `AUTH_RECOVERY_CODES_UNAVAILABLE`.
642
+
643
+ **`@cogenta/auth`** (the priority of this fiche): confirming TOTP enrolment
644
+ now mints ten single-use recovery codes in the same step and hands them back
645
+ — `confirmTotpEnrolment` returns `Promise<RecoveryCodesIssued>` instead of
646
+ `Promise<void>`. New `AuthService` methods: `recoveryCodeLogin`,
647
+ `regenerateRecoveryCodes`, `recoveryCodesStatus`. `passwordLogin`, `totpLogin`
648
+ and `completeWebAuthnLogin` accept an optional `LoginContext` (`userAgent`,
649
+ `ttlMs`) for "remember me" and readable sessions. `SessionStore` gains
650
+ `revokeAllExcept` ("sign out everywhere else") and every session now reports
651
+ a `browser`/`device` pair distilled from the `User-Agent` at creation —
652
+ never the raw header, never an IP address. `CredentialStore` gains
653
+ `setRecoveryCodes`/`recoveryCodesStatus`/`consumeRecoveryCode`/`removeRecoveryCodes`.
654
+ New exports: `generateRecoveryCodes`, `hashRecoveryCode`, `verifyRecoveryCode`,
655
+ `normaliseRecoveryCode`, `RECOVERY_CODE_COUNT`, `parseUserAgent`,
656
+ `ParsedUserAgent`, `LoginContext`, `RecoveryCodesIssued`. Consumption is a
657
+ real compare-and-set on the stored batch (the same idiom `resets.ts` already
658
+ used for password-reset tokens), with a bounded retry against the fresher row
659
+ on a lost race — proven under genuine two-connection SQLite concurrency, code
660
+ by code, in `packages/auth/test/recovery-code-concurrency.test.ts`, alongside
661
+ a naive-control test showing the read-then-write shape it replaces really
662
+ would let one code work twice.
663
+
664
+ **Breaking, honestly**: `confirmTotpEnrolment`'s return type change and the
665
+ new required members on `SessionStore`/`CredentialStore` are real breaks for
666
+ anyone who type-pinned the old signatures or hand-rolled an implementation of
667
+ either store interface — real callers of `createAuthStore`/`createAuthService`
668
+ (the only supported way to get one) are unaffected. Marked `minor` rather than
669
+ `major` per this project's existing 0.x convention (no package has used
670
+ `major` yet, and one now would jump straight to `1.0.0`, which contradicts
671
+ "pre-alpha") — human judgement invited to confirm.
672
+
673
+ **`@cogenta/api`**: new routes `POST /api/auth/recovery-code`,
674
+ `GET /api/auth/password-policy`, `GET /api/auth/totp/recovery-codes`,
675
+ `POST /api/auth/totp/recovery-codes/regenerate`, `POST
676
+ /api/users/me/sessions/revoke-others`, and `GET /api/audit/me` (the one audit
677
+ route open to a non-admin — force-scoped server-side to the caller, never a
678
+ client-supplied id). `POST /api/auth/totp/enrol/confirm`'s response gains
679
+ `recoveryCodes`; `GET /api/users/{id}/sessions` entries gain `browser`,
680
+ `device` and `isCurrent`. New export: `createRecoveryCodeUsedNoticeSource`
681
+ (the security notice a recovery-code sign-in triggers).
682
+
683
+ **`@cogenta/cli`**: `cogenta serve` wires all of the above — the new notice
684
+ source is registered, and a recovery-code sign-in is recorded in the audit
685
+ log as `auth.recovery_code_used` instead of the generic `auth.login`.
686
+ - 2fb2101: Add the editorial site settings screen (fiche 23, ADR-0025's third settings
687
+ category between `cogenta.config.mjs` — infrastructure, read-only — and
688
+ `localStorage` — personal preference).
689
+
690
+ - `@cogenta/schema` gains a typed key/value site-settings store
691
+ (`createSiteSettingsStore`) backed by a closed registry: general (title,
692
+ tagline, admin email, time zone, date/time style), reading (home path,
693
+ posts per page), media (max upload size), and privacy (policy path, cookie
694
+ banner). Every setting has a declared scope (site or per-locale), a default,
695
+ and a required permission; writing an undeclared key is refused.
696
+ - `@cogenta/api` gains `createSitePlanRouter`'s sibling `GET|PATCH
697
+ /api/settings` and extends `GET /api/config-status` with `storage`,
698
+ `llm`/`embeddings`/`imageGeneration`/`vector`, and `billingConfigured` —
699
+ never a secret, never a credential.
700
+ - `@cogenta/cli` wires the new store into `cogenta serve`/`dev`, and
701
+ `theme-render.ts` now serves the configured home path instead of always
702
+ falling back to the hardcoded `/home`.
703
+ - `@cogenta/core` adds `SITE_SETTING_UNKNOWN`/`SITE_SETTING_INVALID` and a
704
+ `secret-hygiene` module the settings screen uses to detect a
705
+ `database.url` with embedded credentials, or a `.env` file readable by
706
+ other users on shared hosting.
707
+ - `create-cogenta` now writes the generated `.env` (which holds
708
+ `COGENTA_AUTH_SIGNING_KEY`) with mode `0o600` instead of the default —
709
+ closing the shared-hosting exposure `docs/hebergement-mutualise.md`
710
+ already named as a known gap.
711
+
712
+ The admin's old single-control "Paramètres" screen (the signed-in account's
713
+ own interface language) moves to "My profile"; `/settings` is now the
714
+ site-wide editorial screen.
715
+ - 0e90b32: Add the "Santé" and "Outils" admin screens (fiche 24), maintenance mode, and a bounded server error journal.
716
+
717
+ - `@cogenta/core`: adds `createErrorLog`, a bounded, redacted ring buffer for the last N server errors — the admin's substitute for reading `stdout` on a host with no access to the process.
718
+ - `@cogenta/schema`: adds `createMaintenanceStore`/`ensureMaintenanceTable` (a one-row on/off switch with a visitor-facing message) and exports `reindexAll`/`reindexEntry` from the search indexer, so a full rebuild reuses exactly what the write path already does on save.
719
+ - `@cogenta/api`: adds `createHealthRouter` (`GET /api/health-report` — literally `cogenta doctor`'s own report, over HTTP; migrations status/apply; audit chain integrity; disk usage; the error log; maintenance mode get/set) and `createToolsRouter` (`GET /api/tools`, `POST /api/tools/{id}/run`, `GET /api/tools/runs[/…]` — seven maintenance tools, always queued, never run inline in the request). Adds a `pending-migrations` notice source.
720
+ - `@cogenta/cli`: `cogenta serve` wires all of the above — `runDoctor` reused unchanged, migrations applied only up to the first destructive one (the CLI is named for the rest), the seven tools (purge caches, reindex search/vectors, regenerate image variants, check links, test email, purge expired trash) running through the existing database-queue driver's degraded tier, and a maintenance-mode gate that serves an uncacheable 503 with a wait page to every anonymous visitor while `/api/*` and `/admin*` stay reachable.
721
+
722
+ Purely additive: `createRequestListener`'s new third parameter is optional, and every `AssembleSiteOptions` addition is optional — a caller that builds a `Site` by hand, or does not pass a migrator, keeps working unchanged.
723
+ - d0bfa1d: Add `@cogenta/export`: content export/import (`export@1.0`, NDJSON, permission-aware),
724
+ media archive export (streaming ZIP, references or full bytes), full-site backup and
725
+ restore (`cogenta-backup@1.0`, engine-independent, checksummed, optionally encrypted
726
+ with a passphrase), and GDPR/RGPD personal-data export by email — fiche 26.
727
+
728
+ `@cogenta/core` gains nine error codes (`EXPORT_*`, `BACKUP_*`, `RESTORE_*`) and exports
729
+ `MEDIA_TABLE`, its media table's physical name, so a caller assembling a full-site
730
+ backup can name every table without depending on `@cogenta/core`'s internals.
731
+
732
+ `@cogenta/cli` gains four new commands: `cogenta export`, `cogenta import content`,
733
+ `cogenta backup create|list`, and `cogenta restore preview|apply`. Restoring a full
734
+ backup is **CLI-only, by design** — it overwrites the database an admin session would
735
+ be running against, so it is never exposed over HTTP; an admin instead applies a
736
+ *content* export (additive, reversible through the trash).
737
+ - 95acedf: Analytics drill-down (fiche 27): pages, referrers, period comparison, custom
738
+ date range, entry-editor stats, CSV export, and configurable, automatically
739
+ purged retention — the gaps found against Jetpack Stats/Plausible/Matomo. No
740
+ new field is collected: every addition is computed from the events row this
741
+ package already wrote (path, referrer domain, device, daily-salted session
742
+ hash), so the site's cookie-free, no-consent-banner posture is unchanged.
743
+
744
+ **`@cogenta/analytics`**: `AnalyticsStore.getSummary` now returns
745
+ `previousTotalViews`/`previousUniqueVisitors`/`viewsChangePercent` — the
746
+ equal-length window immediately before the requested one, with `null` (never
747
+ a misleading `0`) when there is no previous traffic to compare against.
748
+ `getPageStats(path, window)` reports one page's views, previous-period views
749
+ and rank among every path seen in the window — what an entry-editor sidebar
750
+ needs, without pulling the whole top-N list. `purgeEvents(retainDays)` and
751
+ `purgeSalts(retainDays)` delete rows past a configured retention; the events
752
+ table is the largest table on a site with real traffic, and there is no way
753
+ to disable purging outright, only to choose how long to keep.
754
+
755
+ **`@cogenta/core`**: new config section `analytics.retainDays` (default 400
756
+ days), resolved alongside every other site setting.
757
+
758
+ **`@cogenta/api`**: `createAnalyticsRouter`'s `GET /api/analytics/summary`
759
+ accepts a custom `?since=&until=` range (alongside the existing `?days=`),
760
+ reports the period-over-period comparison, and — when the caller wires in
761
+ `resolvePage` — enriches each top page with its entry's title and admin edit
762
+ link. A new `GET /api/analytics/page?path=` answers the same admin-only stats
763
+ for one page. `retainDays`, when wired in, is echoed back as `retentionDays`
764
+ so the admin screen can show a real number instead of a promise.
765
+
766
+ **`@cogenta/cli`**: `cogenta serve` wires the new `analytics.retainDays`
767
+ config into a daily purge tick (same shape as the existing scheduled-publish
768
+ tick — a sweep right away, then one every 24h) and resolves top pages against
769
+ the site's real routes and permission-checked content gateway, so the summary
770
+ screen can link straight to the entry in the admin.
771
+
772
+ Purely additive: a site that never reads `/api/analytics/summary` behaves
773
+ exactly as before.
774
+ - 6e5df34: Fiche 29 — the marketplace gains a real "installed extensions" screen: what
775
+ runs, in which version, with which permissions, and how it's been behaving.
776
+
777
+ **Breaking, in the pre-alpha sense already established for this project (no
778
+ package has ever used `major`, and one would jump straight to `1.0.0`,
779
+ contradicting "pre-alpha"; the breaking shape is called out here instead):**
780
+ `@cogenta/plugins`' `MarketplaceInstallRecord` gains a required `enabled`
781
+ field, and `MarketplacePreview` gains required `engineCompatible`,
782
+ `latestVersion` and `source` fields — anyone constructing these shapes by
783
+ hand (a test double, a custom `MarketplaceInstaller` implementation) needs
784
+ those fields too. `MarketplaceInstaller` gains two new required methods,
785
+ `activate`/`deactivate`, and `uninstall`'s signature grows an optional
786
+ `{ removeData?: boolean }` second argument. `@cogenta/api`'s
787
+ `marketplace-router.ts` mirrors the same shapes structurally, as it always
788
+ has.
789
+
790
+ New, additive:
791
+
792
+ - `@cogenta/plugins`: `createPluginUsageStore` (`permissions/usage.ts`) —
793
+ accumulates real per-run duration, call count, and outcome (ok / error /
794
+ timeout / memory / crash) per plugin, fed by `runPlugin` when given a
795
+ `usageStore` option. `IsolatedRunResult` gains a real, always-present
796
+ `durationMs`. `PluginGrantStore` gains `revokeAll`. The marketplace
797
+ installer gains a manual `enabled` toggle (`activate`/`deactivate`,
798
+ independent of `PluginDisableStore`'s automatic timeout/memory/crash
799
+ disable), an `engineVersion` option that refuses an incompatible install
800
+ or update with the new `MARKETPLACE_ENGINE_INCOMPATIBLE` code (only once a
801
+ caller actually configures a real Cogenta version — the placeholder
802
+ default never fabricates a refusal), and `uninstall(id, { removeData:
803
+ true })`, which also revokes grants and clears the disable/usage records.
804
+ `MarketplaceCatalogEntry` gains an optional `author`, and
805
+ `MarketplaceChangelogEntry` an optional `releasedAt`.
806
+ - `@cogenta/api`: `GET /api/marketplace/installed` (capabilities, disabled
807
+ state, usage, update availability, per item), `GET /api/marketplace/updates`
808
+ and `POST /api/marketplace/updates/apply` (grouped update that always
809
+ skips — never silently applies — anything that would widen permissions),
810
+ `POST /api/marketplace/items/{id}/activate` and `.../deactivate`,
811
+ `POST .../uninstall` now accepts `{ removeData: boolean }` in its body.
812
+ - `@cogenta/core`: new `MARKETPLACE_ENGINE_INCOMPATIBLE` error code, mapped
813
+ to a `422` in `@cogenta/api`'s `statusFor`.
814
+
815
+ Honest limitation, not an oversight: nothing in this repository actually
816
+ calls `runPlugin` yet (no live `AgentRegistry` exists anywhere, the same
817
+ R2-honest gap already noted since L5) — the new usage store is real, tested
818
+ end to end, and wired into `cogenta serve`, but stays empty on a real
819
+ deployment until a real plugin-execution pipeline lands. The installed
820
+ extensions screen says "never run yet" rather than inventing a number.
821
+ - bebbab8: Add store settings for the shop (fiche 34): tax zones/rates with a simulator, shipping
822
+ zones/methods with a simulator, payment driver activation (presence-only for keys, never
823
+ values), general store settings, and a configurable invoice template.
824
+
825
+ - `@cogenta/core` gains a `payment` configuration section (`driver`, `testMode`,
826
+ `manualInstructions`) following the exact `llm`/`billing` pattern: the Stripe secret key
827
+ and webhook secret are never declared in the schema and are refused with
828
+ `CONFIG_SECRET_IN_FILE` if written to `cogenta.config.mjs` — they come only from
829
+ `COGENTA_PAYMENT_STRIPE_SECRET_KEY`/`COGENTA_PAYMENT_STRIPE_WEBHOOK_SECRET`.
830
+ - `@cogenta/schema`'s site-settings registry (fiche 23) gains a `commerce` group
831
+ (currency, tax-inclusive/exclusive display, countries served, minimum order, default
832
+ backorder policy, ToS/return-policy page paths — pointers to real content entries, not
833
+ text fields — and invoice series prefix/payment terms/language) and a new `select`
834
+ `uiType` for closed-choice settings.
835
+ - `@cogenta/commerce`'s admin router gains `GET|POST /tax/rules`, `DELETE
836
+ /tax/rules/{id}`, `POST /tax/simulate` (calls the real resolver, never a second
837
+ implementation), the shipping equivalents (`/shipping/methods`, `/shipping/simulate`),
838
+ and `GET /payment/drivers` / `POST /payment/drivers/{name}/test-connection` (presence
839
+ and live health only, never a key's value). `CommerceAdminRouterOptions` gains required
840
+ `tax`/`shipping` fields and an optional `payment` field — **a breaking change** for any
841
+ direct caller of `createCommerceAdminRouter` that does not yet pass them.
842
+ - `@cogenta/cli`'s `cogenta serve` now selects a real payment gateway through
843
+ `createPaymentRegistry` (Stripe when a key is configured and reachable, bank transfer
844
+ otherwise) instead of a hardcoded manual gateway, and mounts the new commerce settings
845
+ routes.
846
+ - `@cogenta/admin` (private, no changeset) gains four screens under "Boutique": Tax,
847
+ Shipping, Payment, and Store settings (general + invoice template), all `admin`-only.
848
+
849
+ Deliberately not built in this fiche: an inbound `POST /api/commerce/payments/webhook`
850
+ route. `PaymentStore.handleWebhook` is already implemented and tested; wiring it needs
851
+ the raw (non-JSON-parsed) request body, which `cogenta serve`'s shared body reader does
852
+ not yet support for any route. The payment screen shows the webhook URL a deployer would
853
+ configure at Stripe, honestly labelled as not yet receiving events. See `BLOCKERS.md` §15.
854
+ - a8199ea: Media library folders, and the fiche 11 search/filter/sort/pagination/tags/usage/replace
855
+ work — already written and tested, never wired into the admin screen — finally called by
856
+ it (fiche 46).
857
+
858
+ **`@cogenta/core`**: `MediaAsset` gains `folderId: string | null` (`null` means
859
+ unclassified — every asset uploaded before this fiche keeps that value forever, nothing
860
+ backfills it). `CreateMediaInput`/`UpdateMediaInput` gain an optional `folderId`.
861
+ `ListMediaOptions` gains `folderId` (exact match, `null` for unclassified) and
862
+ `folderIds` (an already-resolved set, for "include subfolders"). New: `MediaFolder`,
863
+ `MediaFolderStore`, `createDatabaseMediaFolderStore` — a materialised-path tree
864
+ (same technique as the taxonomy tree, ADR-0022, kept as a *local* copy in
865
+ `folder-path.ts` since `@cogenta/core` cannot depend on `@cogenta/schema`), one
866
+ `cogenta_media_folders` table, folder names unique among siblings. New error codes:
867
+ `MEDIA_FOLDER_NOT_FOUND`, `MEDIA_FOLDER_INVALID`, `MEDIA_FOLDER_NAME_TAKEN`,
868
+ `MEDIA_FOLDER_NOT_EMPTY`, `MEDIA_FOLDER_CYCLE`, `MEDIA_FOLDER_TOO_DEEP`.
869
+
870
+ **`@cogenta/api`**: `media-router.ts` gains `/api/media/folders` (CRUD),
871
+ `/api/media/folders/{id}/move`, `/api/media/{id}/move`, `/api/media/-/bulk-move`, and
872
+ `?folderId=`/`?includeSubfolders=` on `GET /api/media`. `MediaRouterOptions` gains an
873
+ optional `folders?: MediaFolderStore` — absent, the folder routes answer 404 (the same
874
+ graceful-absence shape `usage` already had) and `?folderId=` still works as a plain
875
+ exact match. `STATUS_BY_CODE` gains the six new codes.
876
+
877
+ **`@cogenta/agents`** (no changeset — no observable change): `media.read`/`media.write`
878
+ (contract C) keep exactly the wire output they had before this fiche. `MediaAsset`
879
+ gaining `folderId` would otherwise have grown their shared output schema too — but
880
+ contract C treats an existing tool's signature as figured with no "additive is minor"
881
+ exception (unlike contract A/D, which carry one explicitly), so `folderId` is now
882
+ stripped before that shape is built at all (`toToolAsset`). Exposing it to an agent
883
+ needs a deliberate governance call — a new `tools@1.5` entry permitting additive
884
+ tool-output growth, or a separate tool — left to the human rather than decided here.
885
+
886
+ **`@cogenta/cli`**: `cogenta serve` creates the folder store and bootstraps a default
887
+ `contents` root folder once, idempotently, on every startup; wires `folders` and (a real
888
+ gap found while wiring this fiche's own admin panel — `usage` was written and tested in
889
+ fiche 11 but never actually passed to `createMediaRouter`) `usage` into the media router.
890
+ - 16f63f6: Bring form definitions and submissions closer to parity with premium form plugins (Gravity Forms/WPForms) — fiche 47, tasks 1-4 and 6-11 (task 5, a contract B `form` block, stays out of scope pending its own RFC).
891
+
892
+ - **`@cogenta/forms`**: the field vocabulary gains a tenth kind, **`file`** — a deliberate reopening of ADR-0026's own renoncement, decided live with the user (fiche 47 §8). A `file` field's bytes are sniffed against a closed category vocabulary (`image`/`pdf`/`document`/`text`, via `sniffFormFileCategory`/`assertAllowedFormFile`) — never trusted from a filename or declared `Content-Type` — with a hard, unconfigurable size ceiling (`FORM_FILE_HARD_MAX_BYTES`) on top of any per-field `maxSizeBytes`. `FormFieldDefinition` gains `showIf` (task 1: a field masked by an unmet condition is neither required nor validated, evaluated server-side against the raw submission — `evaluateCondition`/`isFieldVisible`) and `acceptCategories`. `FormDefinition` gains `steps` (task 2: real multi-step forms, validated so every field belongs to exactly one step), `notifyChannels` (task 4: extra Slack/Discord/Telegram/webhook targets via `@cogenta/channels`'s existing `ChannelRegistry`/adapters, never a new transport — `notifyChannels()`) and `captcha` (task 10: optional, off by default, Cloudflare Turnstile verification via `verifyCaptcha`, a single HTTP call, no client SDK dependency). `FormDefinitionStore` gains `duplicate` (task 11: an independent, inactive copy, never carrying submissions over). `FormSubmissionStore` gains `addNote`/`listNotes` (task 8: operator-only notes, never exported) and `list()` gains `query`/`from`/`to` (task 7: full-text search across a submission's own values plus a date range, SQL-filtered then bounded in-memory for the text match — the same honest tradeoff `searchByEmail` already makes). New `csv.ts` (`csvField`/`toCsvRow`/`csvHeaderRow`/`csvSubmissionRow`) mirrors `packages/admin/src/lib/csv.ts`'s CWE-1236 formula-injection guard for the new server-side streamed export (task 9).
893
+ - **`@cogenta/core`**: four new `FORM_*` error codes (`FORM_FILE_REJECTED`, `FORM_CAPTCHA_REQUIRED`, `FORM_CAPTCHA_FAILED`, `FORM_STEP_INVALID`), each mapped to a 4xx status in `@cogenta/api`'s `STATUS_BY_CODE`.
894
+ - **`@cogenta/api`**: `createFormsRouter` gains `storage` (a `StorageDriver`, for the `file` field — absent means every upload is refused rather than silently accepted) and `channelRegistry` options; `POST /api/forms/{name}/submit` now accepts `multipart/form-data` (sniffing and storing any uploaded file before validation), understands multi-step submissions (`_step`/`_accumulated`, answering `202 {status:'step', nextStep, values}` for every step but the last, exactly as before for a single-page form), verifies the CAPTCHA on the final step when a form has one enabled, and dispatches `notifyChannels` alongside the existing e-mail notification. New routes: `POST /api/forms/{id}/duplicate`, `GET`/`POST /api/forms/submissions/{id}/notes`, and `?q=`/`?from=`/`?to=` on `GET /api/forms/submissions`. New export `streamSubmissionsCsv` — an async generator, never buffering the whole export in memory (a single-form export uses that form's own field names as fixed CSV columns; a cross-form export pays one bounded pre-pass to discover columns before streaming rows for real).
895
+ - **`@cogenta/cli`**: `readBody` (`serve.ts`) now parses a real `multipart/form-data` body (reusing `@cogenta/api`'s existing zero-dependency parser) as raw bytes rather than corrupting it through a UTF-8 text decode — this is what makes a `<form enctype="multipart/form-data">` post work with no JavaScript at all, for `/api/forms/*` and (latent, previously dead in production) `/api/media` alike. `forms-page.ts` renders a `file` input, one step at a time for a multi-step form (each step a plain chained `<form method="post">`, no client framework — the original page-load timestamp is carried forward unchanged rather than refreshed, so the anti-abuse fill-delay check keeps its meaning across the whole flow), and the Turnstile widget only on the final step of a form that opted into the CAPTCHA. New route `GET /api/forms/submissions/export.csv` (admin-only, streamed directly to the response, outside `RestResponse`'s JSON-only shape — same reasoning as `/api/media/{id}/file`).
896
+ - Admin (`@cogenta/admin`, private, no changeset): `routes/forms.tsx` gains per-field `showIf`/step/file-category editing (plain text columns on the existing field repeater, not a second visual builder), `notifyChannels`/CAPTCHA configuration, and a Duplicate action; `routes/form-submissions.tsx` gains a search box, a date range filter, internal notes, the referrer (stored since fiche 16 but never shown before), and a server-streamed CSV download (`downloadSubmissionsCsv`) replacing the old 200-row-capped client-side export.
897
+
898
+ A form with none of these features enabled behaves exactly as it did before this change — `steps`/`notifyChannels` default to empty and `captcha` defaults to disabled, and no field's `showIf` means no field's requiredness changed. The form stays fully functional with no JavaScript at every task except the CAPTCHA widget itself, which is opt-in and inherently third-party script.
899
+ - 1dd9e6f: Fiche 53 — coupon per-customer/product limits and a real dunning machine for failed
900
+ subscription renewals, plus the admin screen work pause/resume/billing-history already
901
+ had server-side support for.
902
+
903
+ **Coupons.** `Coupon`/`CreateCouponInput` gain `maxRedemptionsPerCustomer` (on top of,
904
+ never instead of, the existing global `maxRedemptions`) and `restrictedProductIds`
905
+ (commerce product ids; empty means unrestricted). `CouponStore.check()` takes an
906
+ optional fourth `context: { customerId?, productIds? }` argument — existing three-argument
907
+ callers are unaffected — and `CouponCheck` gains two new cases, `customer_exhausted` and
908
+ `not_applicable`; a switch over `CouponCheck.kind` that was exhaustive before this change
909
+ needs a case for both (a real, structural addition to an existing union, called out here
910
+ rather than silently shipped as a patch). `CouponStore.redeem()` now also claims the
911
+ per-customer counter atomically, in the same transaction as the existing global one — a
912
+ customer who loses their own limit's race never burns a global redemption meant for
913
+ someone else. Two new error codes: `COMMERCE_COUPON_CUSTOMER_EXHAUSTED`,
914
+ `COMMERCE_COUPON_NOT_APPLICABLE`. `CouponStore` gains `metrics()`.
915
+
916
+ **Subscriptions — dunning.** `SUBSCRIPTION_STATUSES` gains `past_due` — another
917
+ structural union widening, same caveat as above for an exhaustive switch. A subscription
918
+ lands there the instant a renewal payment fails, and `runBilling`'s own due-query
919
+ (`status = 'active'`) skips it until the cycle resolves. `SubscriptionStore` gains
920
+ `dunning(id)` and `runDunning(options?)`: three retries at 1/3/7 days after the first
921
+ failure by default (the fiche's own proposed calendar, documented as
922
+ `DEFAULT_DUNNING_SCHEDULE_DAYS`, configurable per store via the new
923
+ `SubscriptionStoreOptions.dunningScheduleDays`) — a subscription is never auto-suspended
924
+ before the schedule is exhausted, and `runDunning` replayed on an already-attempted due
925
+ date is a no-op (a compare-and-set on `next_retry_at`, mirroring the scheduler lock in
926
+ `@cogenta/schema`). `pause()`/`resume()`/`cancel()` now also clear an open dunning cycle.
927
+
928
+ **Subscriptions — plan changes.** `SubscriptionStore.changePlan(id, newVariantId,
929
+ options?)` switches the plan immediately with an explicit prorated charge for the rest
930
+ of the current period; a downgrade's credit is reported (`prorationMinor` negative) but
931
+ never silently issued — this store has no credit-note mechanism.
932
+
933
+ **Subscriptions — renewal notices and metrics.** `SubscriptionStoreDependencies` gains
934
+ an optional `notifyRenewal`; `sendRenewalNotices()` is a safe no-op without it (R2). A
935
+ ready-made notifier, `createEmailRenewalNotifier`, is built on `@cogenta/channels`'s own
936
+ `EmailTransport`/`renderEmailMessage` (`@cogenta/commerce` gains a real dependency on
937
+ `@cogenta/channels`) — never a second email renderer. `SubscriptionStore` gains
938
+ `metrics()` (active/past-due/paused/cancelled counts, MRR, churn).
939
+
940
+ **Admin router.** `GET /api/commerce/coupons/metrics`, `GET
941
+ /api/commerce/subscriptions/metrics`, `GET /api/commerce/subscriptions/{id}` (the
942
+ subscription plus its billing history and open dunning cycle), and `POST
943
+ /api/commerce/subscriptions/{id}/change-plan`.
944
+ - 656163e: LLM provider catalog (fiche 56): OpenRouter, DeepSeek, Qwen and GLM are now
945
+ configurable from the admin's "Providers" screen alongside Anthropic, OpenAI
946
+ and Google, plus an explicit "custom provider" option for any other
947
+ OpenAI-compatible endpoint (a self-hosted proxy, or a vendor not yet
948
+ catalogued). No new network code: every OpenAI-compatible entry (OpenRouter,
949
+ DeepSeek, Qwen, GLM, custom) reuses `createOpenAiClient` unmodified, only
950
+ pointed at a different `baseUrl`.
951
+
952
+ **`@cogenta/agents`**: `provider` widens from the closed 3-literal union
953
+ (`'anthropic' | 'openai' | 'google'`) to a plain string, validated at the
954
+ write boundary instead of by a type — `@cogenta/core`'s own
955
+ `llmSchema.provider` was already a free string before this fiche. New
956
+ `providers/catalog.ts`: `KNOWN_PROVIDER_CATALOG` (id/label/wireFormat/
957
+ defaultBaseUrl/knownModels per vendor) and `findProviderCatalogEntry`.
958
+ `createProviderRegistry` resolves a name via the catalog when it knows one
959
+ (dispatching to the right adapter by `wireFormat`), and otherwise requires
960
+ the entry's own `baseUrl` — that pairing (no catalog entry + a `baseUrl`) is
961
+ what "custom provider" means structurally, with no separate flag to keep in
962
+ sync. `createOpenAiClient` gains an optional `name` (defaults to `'openai'`)
963
+ so a client built for OpenRouter/DeepSeek/Qwen/GLM/a custom endpoint reports
964
+ its own id via `ProviderClient.name` — needed for the privacy allowlist
965
+ (`assertProviderAllowed`) to recognise the right vendor rather than every
966
+ OpenAI-compatible client misreporting itself as literally `'openai'`.
967
+ `createFileProviderConfigStore.upsert`/`updateSettings` reject a malformed
968
+ provider id (`PROVIDER_ID_INVALID`) or one outside the catalog with no
969
+ resolvable `baseUrl` (`PROVIDER_CUSTOM_BASE_URL_REQUIRED`) — the write-time
970
+ checks that make network-time resolution failures unreachable.
971
+
972
+ **Breaking (`@cogenta/agents`):** `PROVIDER_NAMES` (the fixed 3-name array)
973
+ is removed — read `KNOWN_PROVIDER_CATALOG` instead, or accept that
974
+ `ProviderName` is now `string`. `ProviderRegistryConfig`'s value shape gains
975
+ nothing new but is now keyed by an open string rather than the closed union.
976
+
977
+ **`@cogenta/core`**: two new error codes, `PROVIDER_ID_INVALID` and
978
+ `PROVIDER_CUSTOM_BASE_URL_REQUIRED` (both 400).
979
+
980
+ **Breaking (`@cogenta/api`):** `providers-router.ts`'s `ProviderRegistryLike`
981
+ gains a required `catalog: readonly ProviderCatalogEntrySummary[]` — any
982
+ caller implementing this interface directly (rather than using
983
+ `@cogenta/cli`'s adapter) must supply it. New route `GET
984
+ /api/providers/catalog` (admin-only) serves it; `catalog` is a reserved
985
+ provider id as a result (a provider literally named "catalog" can no longer
986
+ be created). `POST /api/providers` no longer rejects a provider name outside
987
+ a fixed 3-name list — it rejects a name outside the catalog **only when no
988
+ `baseUrl` is given** (`PROVIDER_CUSTOM_BASE_URL_REQUIRED`, still 400, but a
989
+ different code than the previous generic "not a supported LLM provider"
990
+ `QUERY_INVALID`). `PATCH`/`DELETE /api/providers/:provider` no longer gate
991
+ on a fixed name list at all — they resolve against whatever the store
992
+ actually has saved (a legitimately-saved custom provider used to be
993
+ unreachable by these two verbs; the store's own `PROVIDER_NOT_CONFIGURED`
994
+ already covered "this was never saved").
995
+
996
+ **`@cogenta/cli`**: `packages/cli/src/commands/agent-runtime.ts`'s
997
+ `createProviderRegistryAdapter` now supplies `names`/`catalog` from
998
+ `KNOWN_PROVIDER_CATALOG` instead of the removed `PROVIDER_NAMES`, and no
999
+ longer narrows an arbitrary string against a closed `ProviderName` union
1000
+ before trusting the live registry's own `has`/`get`. `assistant.ts`'s single-
1001
+ provider (`cogenta.config.mjs`'s `llm` section) resolution now accepts any
1002
+ provider `createProviderRegistry` itself can resolve — a catalog id, or a
1003
+ custom id paired with a `baseUrl` — rather than duplicating a fixed 3-name
1004
+ allowlist a second time (the exact desynchronisation risk this repo already
1005
+ hit once with `CONTRACT_C_PERMISSIONS`).
1006
+
1007
+ **`@cogenta/admin`** (unpublished, no changeset entry): the "Providers"
1008
+ screen's "add" form is now catalog-driven — a provider `<select>` populated
1009
+ from `GET /api/providers/catalog`, a known-models picker per selected
1010
+ provider, and an explicit "custom provider" choice (its own id field, and a
1011
+ `baseUrl` the form requires before Save is enabled).
1012
+
1013
+ Not included, by the fiche's own scope: Replicate (a different, asynchronous
1014
+ prediction-and-polling API, not OpenAI-compatible — a separate adapter, left
1015
+ for a later task).
1016
+ - 4513a71: Import gains a real preview/apply/undo flow (fiche 25), on top of the existing
1017
+ one-shot WordPress uploader, which is unchanged and still works.
1018
+
1019
+ `@cogenta/import`:
1020
+ - `analyzeWordPress(xml)` previews a WXR export — counts, proposed collection mapping,
1021
+ authors, media URLs and volume, slug conflicts and everything that will be skipped —
1022
+ without writing anything.
1023
+ - `importWordPress` accepts `{ tracking, runId }`: passed, every post/page/comment it
1024
+ writes is recorded, a second call with the same `runId` resumes without duplicating,
1025
+ and `undoImport` can trash everything the run created (never `purge`, so an
1026
+ over-eager undo is itself reversible from the trash).
1027
+ - New sources: `parseCsv`/`csvToRecords` (zero dependency, RFC 4180), `feedToRecords`
1028
+ (RSS 2.0 and Atom), `parseJsonImport`/`analyzeJson`/`applyJson` (a minimal Cogenta
1029
+ JSON import format). CSV and RSS/Atom share a generic mapping/apply engine
1030
+ (`analyzeGeneric`/`applyGeneric`, `proposeFieldMapping`/`resolveMapping`) against any
1031
+ collection the target site declares — real field correspondence, not a fixed shape.
1032
+ - `createImportTrackingStore` — two new tables (`cogenta_import_runs`/
1033
+ `cogenta_import_items`), owned entirely by this package, never a field on contract A.
1034
+ - Outbound media downloads are now guarded against SSRF (private/loopback/link-local
1035
+ addresses refused, including on a DNS-rebound host name), capped in size and count,
1036
+ and time out.
1037
+
1038
+ `@cogenta/core`: new error codes (`IMPORT_RUN_NOT_FOUND`, `IMPORT_SOURCE_INVALID`,
1039
+ `IMPORT_ALREADY_APPLIED`, `IMPORT_MAPPING_INVALID`, `IMPORT_MEDIA_URL_UNSAFE`,
1040
+ `IMPORT_CSV_INVALID`, `IMPORT_FEED_INVALID`).
1041
+
1042
+ `@cogenta/api`: `createImportRouter` gains `POST /api/import/analyze`,
1043
+ `GET /api/import/runs`, `GET /api/import/runs/{id}`, `POST /api/import/runs/{id}/apply`
1044
+ and `POST /api/import/runs/{id}/cancel`, behind five new optional `ImportRouterOptions`
1045
+ callbacks (`analyze`/`apply`/`getRun`/`listRuns`/`cancel`). All admin-only. The legacy
1046
+ `POST /api/import/wordpress` route is untouched.
1047
+
1048
+ `@cogenta/cli`: `cogenta serve` wires the full flow — WordPress, CSV, JSON and RSS/Atom
1049
+ — through the site's own stores, storage driver and read-only guard.
1050
+ - bdcb563: L20 audit — six real bugs in the admin's diagnostic and dashboard screens,
1051
+ fixed:
1052
+
1053
+ **`@cogenta/core`:** `DriverSelection`/`SkippedDriver` gain `reasonCode`
1054
+ (`DriverSelectionReason`/`SkipReasonCode`) alongside the existing `reason`
1055
+ string — a stable code a translated UI can look up instead of showing
1056
+ `createDriverRegistry`'s English prose ("named in the configuration", "redis
1057
+ not available") verbatim. Purely additive; `reason` is unchanged.
1058
+
1059
+ **`@cogenta/api`:** `HealthDoctorCheck` gains an optional `reasonCode`
1060
+ field, carrying the same information through `GET /api/health-report`.
1061
+
1062
+ **`@cogenta/cli`:** `cogenta serve` now actually constructs a
1063
+ `ScheduledTaskRegistry` and mounts `createScheduledTasksRouter` under
1064
+ `/api/scheduled-tasks` — before this, the admin's "Tâches planifiées" screen
1065
+ had real, tested client and server code on both ends, but nothing ever
1066
+ wired them together, so every request 404'd through the generic content
1067
+ router. The seven recurring jobs that used to run on independent
1068
+ `setInterval`s (scheduled publication, the tools-queue drain, the 404 log
1069
+ purge, audit integrity, the trash sweep, forms GDPR retention, channel
1070
+ notification flush, analytics retention) now run through one heartbeat
1071
+ driving `registry.tick()`, at the same per-task cadence as before — "run
1072
+ now" from the admin is real, and last-run/next-run/history reflect the
1073
+ actual thing. `RuntimeExtras` gains an optional `scheduledTasksRouter`.
1074
+ `DoctorCheck` gains `reasonCode` and a typed `skipped` shape, matching
1075
+ `@cogenta/core`.
1076
+
1077
+ No breaking changes. A caller that never touches the new fields is
1078
+ unaffected; a site with no `ScheduledTaskRegistry` constructed by hand
1079
+ (a test harness building a bare `Site`) simply never gets the route mounted,
1080
+ same degradation as `agentsRouter`.
1081
+
1082
+ Also fixed, admin-only (`@cogenta/admin`, private, no changeset): the
1083
+ "Vues par jour" analytics chart now draws one bar per calendar day of the
1084
+ selected period — zero-filled where the server sent nothing — instead of
1085
+ stretching a sparse response into a single filled rectangle; the "Interroger
1086
+ le site" assistant tab shows an honest state instead of rendering blank when
1087
+ a provider is configured but `assist.chat` specifically is disabled; and the
1088
+ Agents screen degrades to its already-honest "no agent running" empty state
1089
+ instead of showing the raw `"No route matches this path."` wire text when no
1090
+ `AgentRegistry` is mounted (still the case on every real `cogenta serve`
1091
+ today — that gap is documented, not new).
1092
+ - 3cbd6d7: L22 task 5 — OpenTelemetry request tracing, a configurable log level, and
1093
+ an admin "Exploitation" > Observability screen, all on by default and
1094
+ working with zero external service (R1).
1095
+
1096
+ **New package `@cogenta/observability`:** wraps `@opentelemetry/api` +
1097
+ `@opentelemetry/sdk-trace-base` (a real new dependency — see the task
1098
+ report for size and maintenance detail; this is the industry-standard
1099
+ choice, never a hand-rolled tracer). `createObservabilityRuntime` builds
1100
+ one server span per HTTP request (`withRequestTracing`) and a bounded,
1101
+ in-process "recent events" buffer (`ObservabilityRecentStore`, same ring-
1102
+ buffer shape `@cogenta/core`'s `createErrorLog` already uses) that the
1103
+ admin reads. A local NDJSON exporter runs always, needing no external
1104
+ service; an OTLP HTTP exporter runs in addition when an endpoint is
1105
+ configured — never one hardcoded vendor, any OTLP-speaking backend
1106
+ (Grafana, Datadog, …) works. `withRecentLogCapture` wraps any
1107
+ `@cogenta/core` `Logger` so its records also feed the same buffer, gated
1108
+ by a dynamic level getter rather than the logger's own fixed threshold.
1109
+ Every field passes through `@cogenta/core`'s `redact()` before storage —
1110
+ the same discipline the audit log already applies — and a trace only ever
1111
+ carries a request's method, path (query string stripped) and status code,
1112
+ never a header, cookie, or body.
1113
+
1114
+ **`@cogenta/core`:** a new `observability` config section
1115
+ (`cogenta.config.mjs`) — `serviceName` and `otlpEndpoint`, resolved
1116
+ always, defaults needing nothing external. No `otlpHeaders` field, on
1117
+ purpose (rule R7, same shape as `payment`'s missing `stripeSecretKey`):
1118
+ those come from `COGENTA_OTLP_HEADERS`/`OTEL_EXPORTER_OTLP_HEADERS` only,
1119
+ refused if written to the file (`CONFIG_SECRET_IN_FILE`). `serviceName`
1120
+ and `otlpEndpoint` also honour the standard `OTEL_SERVICE_NAME`/
1121
+ `OTEL_EXPORTER_OTLP_ENDPOINT` environment variables as a fallback.
1122
+
1123
+ **`@cogenta/schema`:** `SITE_SETTINGS_REGISTRY` gains a new `observability`
1124
+ group with two editorial settings — `observability.enabled` (default on)
1125
+ and `observability.logLevel` (`error`/`warn`/`info`/`debug`, default
1126
+ `info`) — changeable from the admin with no restart, unlike the OTLP
1127
+ export destination above.
1128
+
1129
+ **`@cogenta/api`:** `createObservabilityRouter` — `GET /api/observability`,
1130
+ admin-only, read-only, answering the current `enabled` state plus the
1131
+ recent traces and logs.
1132
+
1133
+ **`@cogenta/cli`:** `cogenta serve` wires all of the above — the HTTP
1134
+ listener is wrapped with `withRequestTracing`, the shared logger is
1135
+ wrapped with `withRecentLogCapture`, and `observability.enabled`/
1136
+ `observability.logLevel` are polled from the settings store every 15s
1137
+ (configurable via `ServeOptions.observabilitySettingsTickMs`, a test
1138
+ seam) so an admin's change takes effect without a restart.
1139
+ - 249eb6f: Add the update system (L22 task 9): checking npm for a newer `@cogenta/core`/
1140
+ `@cogenta/cli`, and applying one with a mandatory restore point first — never an
1141
+ update with no safety net.
1142
+
1143
+ `@cogenta/core` gains `readOwnPackageVersion` (self-describing package version,
1144
+ read from a package's own `package.json`, never bundled at build time) and
1145
+ `getCoreVersion`, its own version computed with it — **lazily, cached after the
1146
+ first real call, never a top-level constant**: a top-level `CORE_VERSION =
1147
+ readOwnPackageVersion(...)` was the first design, and it broke every
1148
+ `@cogenta/admin` test that happened to pull `@cogenta/core` in transitively,
1149
+ because that suite's `import.meta.url` is not a `file://` URL under
1150
+ Vitest+jsdom's Vite transform. `@cogenta/core` is imported (for types) by
1151
+ enough of this monorepo, including browser-bundled code, that nothing at its
1152
+ top level may assume a real Node `file://` module URL — fixed before it ever
1153
+ shipped, but worth naming so the next self-describing constant doesn't repeat
1154
+ it. New error codes: `PACKAGE_VERSION_UNREADABLE`, `UPDATE_CHECK_FAILED`,
1155
+ `UPDATE_RESTORE_POINT_FAILED`, `UPDATE_APPLY_FAILED`, `UPDATE_NOT_AVAILABLE`,
1156
+ `UPDATE_CONFIRMATION_REQUIRED`, `UPDATE_POLICY_INVALID`.
1157
+
1158
+ `@cogenta/schema` gains one new site-settings-registry entry,
1159
+ `updates.autoUpdatePolicy` (`off`/`patch`/`patch-minor`/`patch-minor-major`, off by
1160
+ default) — a normal editorial setting through the existing generic settings store,
1161
+ no new persistence mechanism.
1162
+
1163
+ `@cogenta/api` gains `createUpdateRouter`: `GET /api/updates/status` (a live
1164
+ version check against npm, per package), `GET /api/updates/history` (past
1165
+ checks/applies plus the restore points they took), and `POST /api/updates/apply`
1166
+ (admin-only, every route).
1167
+
1168
+ `@cogenta/cli` gains `cogenta update check|apply|history`, wired the same way into
1169
+ `cogenta serve`'s admin API and into a new daily `updates-auto-check` scheduled
1170
+ task that honours `updates.autoUpdatePolicy` — never auto-applies a version whose
1171
+ changelog scan flagged a frozen contract, and never re-applies the same version on
1172
+ every tick after a successful auto-apply (this process's own version constant
1173
+ cannot change without an actual restart).
1174
+
1175
+ A **real bug fix**, found while wiring `getCliVersion`: `bin.ts` never passed its
1176
+ own version to `run()`, so `cogenta version`/`cogenta --version` always printed the
1177
+ fallback `"0.0.0"` regardless of what was actually installed. Fixed.
1178
+
1179
+ **Contract-risk detection is real but honestly limited.** It reads the target
1180
+ version's own published `CHANGELOG.md`, fetched from its npm tarball
1181
+ (`registry.npmjs.org` only, a small zero-dependency ustar/pax reader — no `tar`
1182
+ dependency, R9) and scanned for a frozen-contract mention. `@cogenta/core` and
1183
+ `@cogenta/cli` add `CHANGELOG.md` to their own `"files"` for this to work — every
1184
+ version already published before this ships has no `CHANGELOG.md` in its tarball
1185
+ (verified with a real `npm pack` while building this), so the check reports an
1186
+ honest "could not determine" for those rather than a false "no risk found." Even
1187
+ once readable, this is a keyword scan of prose, not comprehension — a strong hint
1188
+ an admin reviews before confirming, never a certification.
1189
+
1190
+ **Out of scope, deliberately**: this updates a site's npm packages only — `cogenta
1191
+ build`/`deploy` remain honestly deferred (L9), and no migration ever runs
1192
+ automatically (`cogenta migrate status`/`migrate up` stay a separate, explicitly
1193
+ confirmed step, exactly as today).
1194
+ - 4d3f3c7: L24 task 1: the agent execution loop (`packages/agents/src/runtime/loop.ts`, `runAgentLoop`) now runs as a two-node LangGraph.js `StateGraph` (`agent` → `tools` → `agent`) instead of a hand-written `for` loop. This was requested directly by the project owner after an earlier refusal of LangGraph in L22 (R9 — the hand-rolled loop was under 300 lines and sufficient at the time); the owner re-requested it for long-term maturity and stability, tracked in ADR-0029 (text ready, awaiting human insertion into `docs/03-decisions.md`).
1195
+
1196
+ **New direct dependency**: `@langchain/langgraph` (`^1.4.12`). Pure ESM, TypeScript, no native code (R10 n/a — nothing to WASM-fallback). Pulls ~16 transitive packages, including `@langchain/core` (peer dependency, resolved automatically, not added as a direct dependency of `@cogenta/agents` since nothing here imports from it — only `StateGraph`, `Annotation`, `START`, `END`, `GraphRecursionError` are used, never LangChain's message types or its own tool-calling/agent abstractions) and, further down, `langsmith` — LangChain's proprietary tracing SDK. **`langsmith` is never called, configured, or reachable from any code in this repository**; it is a transitive pull with no on/off switch, not a forgotten integration. Flagged here explicitly so a future dependency audit does not mistake silence for an oversight.
1197
+
1198
+ **What did not change**: `runAgentLoop`'s public signature (`RunAgentLoopInput` in, `RunResult` out) is untouched, so every existing caller — `agents/orchestrator.ts` (`createAgentRunner`), `subagents/run-subagent.ts`, `tools/core/agent-delegate.ts`, `assist/runtime.ts`, `eval/run-suite.ts` — needed no changes at all. All three autonomy levels (`report-only`/`co-pilot`/`autopilot`) and the built-in agents behave identically from the admin's point of view. Contract C (`buildManifest`/`createToolRegistry`) does not change shape.
1199
+
1200
+ **R4, proven not assumed**: the graph's `tools` node contains zero permission logic — it calls a new exported primitive, `runTool`, which does nothing but look up a tool by name and call `.execute()`. The only thing standing between a model's tool-call request and a real side effect is whether the `ExecutableTool` object the node was handed was wrapped by `withAutonomy` before the graph ever saw it — a decision made entirely in `agents/orchestrator.ts`, outside and above the graph, exactly as before the migration. `packages/agents/test/runtime/loop.test.ts` adds three tests that prove this rather than assume it survived: `runTool` given a *raw* tool executes the real side effect (showing the node itself supplies no gate — if it did, this call would be blocked too); the same primitive given the *same* tool `withAutonomy`-wrapped at `observe` never reaches the side effect; and a full `runAgentLoop` run, with a model that asks for the same "dangerous" tool on three consecutive turns, never triggers it once.
1201
+
1202
+ `@cogenta/core` gains one new error code, `AGENT_LOOP_RECURSION_LIMIT` — a defensive backstop thrown only if LangGraph's own recursion ceiling were ever hit before `runAgentLoop`'s pre-existing `max_steps` guard fires first (the ceiling is set to `maxSteps * 2 + 10`, comfortably above what the guard needs, so this should be unreachable in practice; it exists to fail loudly rather than silently if that assumption is ever wrong).
1203
+
1204
+ `deps-auditor` was invoked on this addition before committing, per R9. Verdict: accept — MIT-licensed throughout, ESM, no native code, actively maintained (all four LangChain packages checked were last published within two days of this addition), and the alternative was already weighed and rejected once (L22, R9) before the owner explicitly re-requested it for long-term ecosystem maturity. One additional watch point beyond `langsmith`: the transitive tree carries two non-deduplicated versions of `p-queue` (6.6.2 via `langsmith`, 9.x via `@langchain/langgraph-sdk`) — no measured functional impact, worth revisiting only if `node_modules` size becomes a constraint on shared/mutualised hosting.
1205
+ - cb62917: `cogenta mcp --api-key <key>` — resolve the MCP server's actor from a real API
1206
+ key, and a dedicated admin screen to mint one (L21 task 6).
1207
+
1208
+ Until now, `cogenta mcp` could only run as a real user (`--email`), a
1209
+ synthetic test actor (`--role`), or anonymous — there was no way to generate
1210
+ a credential for an MCP client from the admin, the way REST already lets you
1211
+ via "Clés API". `--api-key` closes that gap by resolving through the exact
1212
+ same `ApiKeyStore` (`@cogenta/auth`) and "roles = scope" mapping REST's own
1213
+ `resolveActor` uses for a `cogenta_sk_…` bearer token — one store, two
1214
+ callers, never a second lookup path. A role the key was not granted is
1215
+ refused by the same `PermissionLayer` REST uses, exactly as it would be over
1216
+ HTTP (R4). `@cogenta/core` gains `MCP_ACTOR_API_KEY_INVALID` for an unknown,
1217
+ revoked or expired key.
1218
+
1219
+ The admin gains a new **MCP** screen (`@cogenta/admin`, private, no
1220
+ changeset entry of its own), parallel to "Agents" rather than folded into
1221
+ the generic "Clés API" screen — same underlying key store, different
1222
+ audience: generating a key here also shows a ready-to-paste `cogenta mcp
1223
+ --api-key …` command and a standard MCP client JSON configuration block,
1224
+ both built from the raw key the server just returned, shown exactly once,
1225
+ same as the existing screen's own raw-key handling.
1226
+
1227
+ See `packages/mcp/README.md` for the updated connection instructions.
1228
+ - 5e43b20: `cogenta mcp` — a real MCP (Model Context Protocol) server, wired in (L20 audit).
1229
+
1230
+ `@cogenta/mcp`'s server/transport existed, tested and unused since it shipped:
1231
+ no CLI command ever invoked it. `cogenta mcp` starts it for real, on the
1232
+ process's own stdin/stdout, built from this site's actual tool manifest
1233
+ (`buildManifest`, `@cogenta/agents`) — the same shape `cogenta serve` builds
1234
+ for REST/GraphQL, not a second implementation.
1235
+
1236
+ **R4 applied for real**: `--email <email>` resolves the acting user from the
1237
+ site's own user store and runs every tool call with that user's real roles,
1238
+ checked by the same `PermissionLayer` REST and GraphQL use. `--role
1239
+ <role,role>` hands a synthetic actor for local testing. With neither, calls
1240
+ run as the anonymous `public` actor — content tools stay on the manifest and
1241
+ stay permission-checked (a public actor sees only what a public actor may
1242
+ see); media, site-config and HTTP-fetch tools, which have no permission
1243
+ check of their own, are left off the manifest entirely rather than exposed
1244
+ by default.
1245
+
1246
+ `@cogenta/core` gains three error codes: `MCP_ACTOR_OPTIONS_CONFLICT`,
1247
+ `MCP_ACTOR_USER_NOT_FOUND`, `MCP_ACTOR_ROLE_EMPTY`.
1248
+
1249
+ See `packages/mcp/README.md` for how to connect Claude Desktop, Claude Code
1250
+ or Cursor, and `BLOCKERS.md` §18 for the one known limitation: content
1251
+ written through this path is not (yet) re-indexed for search/vectors or
1252
+ redirect-tracked the way `cogenta serve`'s own write path is.
1253
+ - b8d307a: Fiche 58: the "MCP" admin screen renamed to "MCP Server" (nav/i18n only, no
1254
+ functional change — task 1), and a real MCP **client**: this site's own agents can
1255
+ now consume external MCP servers, gated by a security review (`security-reviewer`,
1256
+ 2026-08-26 — NO-GO as originally written, GO conditional on a sandboxing floor,
1257
+ re-reviewed against this final implementation before merge).
1258
+
1259
+ **`@cogenta/mcp`**: `createMcpStdioClient` no longer inherits `process.env` —
1260
+ `spawn` receives exactly `options.env ?? {}`, never the host's real environment
1261
+ (the critical finding: the previous default handed a spawned third-party process
1262
+ every secret this server had, `COGENTA_AUTH_SIGNING_KEY` included, before
1263
+ `initialize()` was ever called). `stdio` is always `['pipe', 'pipe', 'pipe']`,
1264
+ never `inherit` — stderr is captured and logged through the structured logger,
1265
+ capped in size. Every JSON-RPC call has a hard timeout that kills the process and
1266
+ rejects every pending call on the connection; `wrapMcpTool`'s `execute` now honours
1267
+ `ctx.signal` too, so a run's own cancellation reaches the remote process the same
1268
+ way. A best-effort memory/CPU watchdog polls the spawned PID (`ps`/PowerShell, no
1269
+ native dependency — R9/R10); the real limit is host-level (cgroup, Job Object),
1270
+ documented as a prerequisite, not a guarantee.
1271
+
1272
+ New `packages/mcp/src/registry/`: `McpConnectionStore` (table `mcp_connections`,
1273
+ secret encrypted at rest with the same AES-256-GCM/`COGENTA_AUTH_SIGNING_KEY`
1274
+ scheme as `@cogenta/agents`' `ProviderConfigStore` — R7), `discoverMcpConnection`
1275
+ (a real `initialize()` + `tools/list()` probe through the sandboxed client),
1276
+ `buildMcpToolDefinitions` (wires every enabled connection's checked tools into
1277
+ Contract C `ToolDefinition`s). `McpConnectionStore.create()` structurally refuses a
1278
+ `stdio` connection without `confirmUnsandboxed: true` — the mandatory, honest
1279
+ acknowledgement that this binary runs with the Cogenta process's own full OS
1280
+ privileges, unsandboxed beyond this package's floor; a UI can show the warning, but
1281
+ the refusal itself lives here. `setExposedTools()` refuses a remote tool name never
1282
+ actually seen in the connection's last discovered list — "absent, pas refusée": a
1283
+ tool the admin never checked is never wrapped for any agent. `http` is a stored
1284
+ transport (forward-compatible schema) with no working client yet — honestly
1285
+ refused (`discoverMcpConnection`), never silently pretended to work.
1286
+
1287
+ **Contract C → `tools@1.4`** (`docs/04-contrats.md`): the parameterised permission
1288
+ `mcp.external:<connectionId>.<remoteToolName>` — one permission per checked remote
1289
+ tool, never per connection (`mcp.external.<connexion>` was rejected by the security
1290
+ review: it would grant every checked tool on a connection indifferently of its own
1291
+ risk, contradicting the "case à cocher par outil" principle and weakening R4). No
1292
+ existing tool signature changes — additive to an open taxonomy, the same kind of
1293
+ change `document.extract`/`logs.read`/`redirects.write`/`code.patch` already were.
1294
+
1295
+ **`@cogenta/core`**: ten new error codes — `MCP_CLIENT_CALL_TIMEOUT`,
1296
+ `MCP_CLIENT_CALL_ABORTED`, `MCP_CLIENT_PROCESS_EXITED`, `MCP_CLIENT_SPAWN_FAILED`,
1297
+ `MCP_CLIENT_CLOSED`, `MCP_CLIENT_RESOURCE_EXCEEDED`, `MCP_CONNECTION_NOT_FOUND`,
1298
+ `MCP_CONNECTION_INVALID`, `MCP_CONNECTION_AUTH_INVALID`,
1299
+ `MCP_CONNECTION_CONFIRMATION_REQUIRED`, `MCP_CONNECTION_TOOL_NOT_DISCOVERED`.
1300
+
1301
+ **`@cogenta/api`**: new `createMcpConnectionsRouter` (`/api/mcp-connections`,
1302
+ admin-only) — list/create/enable-disable/remove, `POST .../test` (a real discovery
1303
+ probe), `PUT .../exposed-tools` (the admin's checkbox decision). A new direct
1304
+ dependency on `@cogenta/mcp` (internal workspace package, not a third-party
1305
+ addition) for `discoverMcpConnection` and the store's types.
1306
+
1307
+ **`@cogenta/cli`**: `cogenta serve` creates the connection table and store
1308
+ unconditionally (usable even without an LLM provider configured, same posture as
1309
+ `/api/api-keys`); `packages/cli/src/commands/agent-runtime.ts`'s `buildAgentRuntime`
1310
+ merges every enabled connection's checked tools into the site's real tool registry
1311
+ through a live-swappable wrapper (`createLiveToolRegistry`) — a connection
1312
+ created/tested/exposed from the admin screen becomes callable by an agent on its
1313
+ very next lookup, no `cogenta serve` restart, the same "no restart needed"
1314
+ guarantee `/api/providers` already gives. `AgentRuntimeAssembly` gains
1315
+ `refreshMcpTools()` and `mcpDispose()` (closes every spawned `McpClient` and
1316
+ removes every sandbox working directory on server shutdown). The fiche names
1317
+ `packages/agents/src/runtime/` for this wiring; it lives in `@cogenta/mcp`/
1318
+ `@cogenta/cli` instead — `@cogenta/mcp` already depends on `@cogenta/agents`, so
1319
+ the reverse dependency the fiche's own path would need is a package cycle. Deviation
1320
+ signalled, not silently worked around.
1321
+
1322
+ Tests: `@cogenta/mcp` — the sandboxing floor (no inherited environment variable
1323
+ proven by inspecting what `spawn` actually receives while a real host secret is
1324
+ set; a hung server killed and rejected under a configured timeout; per-call abort;
1325
+ stderr capture), the connection store (confirmation requirement, encrypted secret,
1326
+ "absent, pas refusée"), discovery, and `buildMcpToolDefinitions` (one client shared
1327
+ across a connection's tools, a failed connection skipped not thrown, an end-to-end
1328
+ call through a fake stdio server). `@cogenta/api` — admin-only, the confirmation
1329
+ refusal, "absent, pas refusée" at the REST boundary. `@cogenta/cli` — a real,
1330
+ spawned `node` process (`test/fixtures/fake-mcp-server.mjs`) driven end to end
1331
+ through a real `cogenta serve`/SQLite/HTTP stack: connection created, tested,
1332
+ exposed, called by a real agent run with a scripted LLM vendor, proving the actual
1333
+ child process received none of the host's real environment
1334
+ (`COGENTA_AUTH_SIGNING_KEY` included) and that disabling a connection removes its
1335
+ tool from what an agent can call without a restart.
1336
+ - 54409f3: Media library (fiche 11): tags, usage tracking, in-place replace, and richer
1337
+ listing.
1338
+
1339
+ **Breaking for a custom `MediaStore` implementation**, written as `minor`
1340
+ following this project's established pre-alpha convention (0.x, no package
1341
+ has ever used `major`, and one here would jump straight to `1.0.0` — which
1342
+ "pre-alpha" contradicts). `@cogenta/core`'s `MediaStore` interface gains two
1343
+ new required methods, `count()` (the total match count ignoring
1344
+ `limit`/`cursor`, so the admin can show "2,000 assets" instead of only "there
1345
+ is another page") and `replace()` (overwrite the bytes behind an existing id
1346
+ in place — every entry and block already holding that id keeps working,
1347
+ unchanged). `MediaAsset` gains two new required fields: `tags` (free-form
1348
+ labels, not a hierarchy — an asset commonly belongs to more than one subject
1349
+ at once) and `contentHash` (a short digest of the stored bytes, folded into
1350
+ `/_image` URLs as `&v=` to bust the year-long immutable cache when an asset
1351
+ is replaced — never a secret, never used for integrity). The only
1352
+ implementation in this repo, `createDatabaseMediaStore`, is updated; a
1353
+ third-party driver is not.
1354
+
1355
+ Backward-compatible additions: `CreateMediaInput`/`UpdateMediaInput` gain
1356
+ optional `tags`; `ListMediaOptions` gains `tag`, `from`/`to` (created-at
1357
+ range), `sort` (`MediaSortField`: `createdAt`/`filename`/`size`), and
1358
+ `direction`. `@cogenta/render`'s `MediaAsset` gains an optional `version`
1359
+ field (`theme@1.2`) — absent is fully backward compatible, exactly today's
1360
+ behaviour with no `&v=` appended.
1361
+
1362
+ `@cogenta/api`'s `createMediaRouter` gains real multipart parsing
1363
+ (`packages/api/src/rest/multipart.ts`, zero new dependency — R9/R10), a
1364
+ `POST /api/media/{id}/replace` route, `tag`/`from`/`to`/`sort`/`direction`
1365
+ query parameters on the list route, and EXIF GPS stripping on upload and
1366
+ replace (`stripGps`, opt-out per request, default on — a photo's location is
1367
+ not something an editor usually means to publish).
1368
+
1369
+ `@cogenta/schema` gains `findMediaUsage` (`packages/schema/src/media-usage.ts`):
1370
+ scans every collection's entries for a media id in a `media`/`richText`/
1371
+ `blocks` field and reports where it is referenced, so the admin can warn
1372
+ before deleting an asset still in use rather than after. `titleOf` (from
1373
+ `search/extract.ts`) is now exported — `findMediaUsage` needed the same
1374
+ "what does an editor call this entry" logic the search indexer already had,
1375
+ and duplicating it would have drifted.
1376
+ - 2285720: Menus gain a real editor (fiche `docs/plans/09-menus.md`):
1377
+
1378
+ - **Edit an item in place.** `PATCH /api/menus/{id}/items/{itemId}` now accepts `label`, `kind`, the target fields, `title` and `openInNewTab` — no more delete-and-recreate to fix a typo. Changing `kind` clears the previous target rather than keeping a value that no longer applies. `parent` is deliberately not accepted here; re-parenting still goes through `POST .../move`.
1379
+ - **Bulk, transactional reorder.** `MenuStore.reorderItems` and `PATCH /api/menus/{id}/items` rewrite `parent`/`position` for any number of items in a single transaction, so a drag-and-drop or keyboard reordering session commits (or fails) as one unit — never a partially-rewritten tree if the network drops mid-session.
1380
+ - **Menu locations.** `Menu` gains `location: string | null` (`byLocation`, `GET /api/menus/by-location/{location}`) — where a menu renders (`primary`, `footer`, …), carried by the menu itself rather than baked into a theme's name convention. `@cogenta/cli`'s `ThemeRenderOptions` gains `headerMenuLocation`/`footerMenuLocation`, resolved generically by location with a fallback to the legacy `main`/`footer` name lookup, so an existing site's navigation keeps rendering unchanged. `@cogenta/core` gains the `MENU_LOCATION_TAKEN` error code for the one-menu-per-location-per-locale rule.
1381
+ - **Two new item kinds.** `taxonomy` (links to a term) and `home` (always resolves to `/`) join `entry`/`url`/`submenu-placeholder`.
1382
+ - **Target health.** A menu item resolver may now report `health` (`published`/`draft`/`scheduled`/`archived`/`trashed`) for an `entry` item — computed only for an actor whose role already has draft access to the target collection, so a public read never learns that a draft exists. `cogenta serve`'s public render hides a dead `entry`/`taxonomy`/`home` link entirely rather than serving one.
1383
+
1384
+ All additions are backward compatible: `resolveEntry` gained a third `context` parameter and an optional `health` on its result, but a two-argument resolver still satisfies the type; every new field is optional or nullable on the wire.
1385
+ - 9b1dae8: Fiche 43 sub-chantiers A, B, E, F (Cogenta Page Builder — motifs, copier/coller, verrouillage/sélection multiple, import/export) — extends the L16 visual page builder without touching contract A, B, C or D.
1386
+
1387
+ **Sub-chantier A — pattern/model library.** `@cogenta/schema` gains a new
1388
+ one-fixed-table store (`ensurePatternTables`/`createPatternStore`,
1389
+ `cogenta_patterns`), the same "not schema-declared, one fixed pair/table"
1390
+ treatment `menu-tables.ts` already gets — a pattern is a reusable *shape* an
1391
+ editor composes from existing blocks, never a thirteenth block type. Two
1392
+ kinds share the table: a **motif** (a few blocks, added to whatever a page
1393
+ already has) and a **modèle de page complet** (replaces the whole block
1394
+ zone, and only ever behind explicit confirmation in the admin — never
1395
+ silently). `@cogenta/api` gains `createPatternRouter` (`/api/patterns`,
1396
+ admin/editor only on every method, mirroring `redirect-router.ts`'s fixed
1397
+ door) with two new error codes on `@cogenta/core`, `PATTERN_UNKNOWN`/`PATTERN_INVALID`. A
1398
+ pattern's blocks are validated against the site's block registry
1399
+ (`@cogenta/blocks`'s `vocabularyRegistry` by default, overridable) exactly
1400
+ the way a clipboard paste is: one unknown block type refuses the whole
1401
+ pattern, never a partial or best-effort insert. `@cogenta/cli` wires both
1402
+ into `cogenta serve` (`ensurePatternTables` at boot, `/api/patterns` mounted
1403
+ next to `/api/menus`) and into `cogenta backup`/`cogenta restore`
1404
+ (`PATTERN_TABLE` added to the table list `buildBackupTables` already
1405
+ assembles).
1406
+
1407
+ **Sub-chantier B — copy/paste and reusable blocks.** Purely client-side
1408
+ (`@cogenta/admin`, no published package touched): `Ctrl/⌘+C`/`Ctrl/⌘+V` on
1409
+ the builder's block selection, through the browser clipboard as
1410
+ `cogenta/blocks@1`-tagged JSON, validated the same way on paste (unknown
1411
+ block type named and refused). "Blocs réutilisables" is deliberately not a
1412
+ second mechanism — fiche 05 task 3's own recommendation — a single-block
1413
+ pattern already covers it: insertion is always a copy, never a live
1414
+ reference, so there is nothing in contract B to touch.
1415
+
1416
+ **Sub-chantier E — lock and multi-select.** Also admin-only. A lock is a
1417
+ session-only admin flag, never persisted to contract B or the server; a
1418
+ locked block cannot be moved (by its own controls, by a neighbour's move
1419
+ displacing it, or as part of a group move) or removed. Multi-select is
1420
+ scoped to the outline list (`Shift`+click), never the preview — the same
1421
+ `Shift`+click a keyboard/switch user can also drive, with named group
1422
+ buttons doubling every drag, per the lot's own rule. A group move/remove is
1423
+ always one undo step, never one per block.
1424
+
1425
+ **Sub-chantier F — import/export.** A pattern library round-trips through a
1426
+ versioned JSON file (`cogenta/pattern-file@1`), validated block-by-block on
1427
+ import the same way a save is. `provenance`/`provenanceDetail` follow
1428
+ contract A's own values (`human`/`assisted`/`generated`) — a pattern an
1429
+ agent generates is never indistinguishable from one a person authored by
1430
+ hand.
1431
+
1432
+ `cogenta_patterns` has the same one-suite-run-four-times contract test as
1433
+ `taxonomy-store.ts`/`content-store.ts` (`pattern-store.contract.ts`,
1434
+ SQLite as a unit test and Postgres/MySQL/MariaDB as loud-skip integration
1435
+ tests) — deliberately not left SQLite-only the way `menu-store.ts`'s own
1436
+ table predates this discipline and still is.
1437
+
1438
+ No contract touched: A, B, C and D are all unchanged. `PermissionLayer`
1439
+ gains no new method — pattern management is a fixed admin/editor rule, the
1440
+ same shape `redirectRouter`/`menuRouter` already use, and *inserting* a
1441
+ pattern's blocks into an entry still goes through the entry's own existing
1442
+ `update` permission (`POST /api/builder/render`'s `PermissionLayer.assert`),
1443
+ unchanged.
1444
+ - 8a8d873: Add PayPal as a third, independently registered payment driver — proof that the payment
1445
+ gateway a shop uses is not a fixed Stripe/bank-transfer pair but an open registry
1446
+ (`@cogenta/commerce`'s `PaymentGateway` interface, the same `Driver<T, Config>` shape as
1447
+ cache/queue/storage), the concrete answer to "what if I don't want Stripe?".
1448
+
1449
+ - `@cogenta/commerce` gains `payment/paypal.ts` (`paypalPaymentDriver`), written against
1450
+ PayPal's REST Orders v2 / Payments v2 API with `fetch`, no new dependency (R9), the same
1451
+ discipline as `payment/stripe.ts`: OAuth2 client-credentials token caching, a real
1452
+ RSA-SHA256 webhook signature check against a certificate fetched from
1453
+ `paypal-cert-url` (trusted only when its origin matches `apiBaseUrl` or is a genuine
1454
+ `*.paypal.com` host — a forged cert-url header cannot "verify" against its own key), a
1455
+ freshness window, and an explicit event whitelist so an unrecognised PayPal event is
1456
+ refused rather than guessed as `paid`. `fetch()` captures an order the moment it sees
1457
+ `APPROVED` (there is no separate capture verb in this project's narrow
1458
+ `PaymentGateway` interface), tolerating the one real race a concurrent poll can hit
1459
+ (`ORDER_ALREADY_CAPTURED`) by re-reading the order instead of failing. Registered in
1460
+ `payment/registry.ts` alongside Stripe (both `optimal`) ahead of the always-available
1461
+ `manual` driver (`degraded`).
1462
+ - `@cogenta/core`'s `payment` configuration section gains `paypal` as a named driver and
1463
+ three secret fields (`paypalClientId`, `paypalClientSecret`, `paypalWebhookId`), refused
1464
+ in `cogenta.config.mjs` the same way Stripe's are and sourced only from
1465
+ `COGENTA_PAYMENT_PAYPAL_CLIENT_ID` / `COGENTA_PAYMENT_PAYPAL_CLIENT_SECRET` /
1466
+ `COGENTA_PAYMENT_PAYPAL_WEBHOOK_ID`.
1467
+ - `@cogenta/cli`'s `cogenta serve` passes the three PayPal fields through to
1468
+ `createPaymentRegistry` alongside the existing Stripe ones — no other wiring changed.
1469
+ - `@cogenta/admin` (private, no changeset): the payment screen is rebuilt from a
1470
+ two-card grid into a real provider list (WooCommerce's "Payment providers" pattern) —
1471
+ each row shows tier, configured/not-configured, active, and its own test-connection
1472
+ button — so a third driver appears with no change to the component, proving the point
1473
+ visually rather than only in code.
1474
+
1475
+ PayPal's sandbox and live environments are different hostnames (`api-m.sandbox.paypal.com`
1476
+ vs `api-m.paypal.com`), unlike Stripe's single host with a test/live key prefix — an
1477
+ operator testing against the sandbox sets `payment.apiBaseUrl` explicitly, the same escape
1478
+ hatch the driver's own test suite uses to point at a local HTTP stub.
1479
+ - 3075941: Fiche 45 — Prompt Settings, a shared, editable library for every utility prompt an `assist.*` tool sends the model. Until now, each instruction line (`assist.rewrite`, `assist.proofread`, `assist.summarise`, `assist.translate`, `assist.meta_description`, `assist.titles`, `assist.tags`, `assist.alt_text`, `assist.classify`, `assist.moderate`, `assist.faq_draft`, `assist.schema_org_draft`, `assist.chat`) was a literal string baked into the package.
1480
+
1481
+ `@cogenta/agents` gains a new `prompts/` module: `PromptTemplateStore` (`createFilePromptTemplateStore` — one JSON file per template, same "real but local" tier as the existing agent/skill/provider stores, R1), `renderPromptTemplate`/`resolveInstruction` (`{{field}}` placeholder substitution that throws `PROMPT_TEMPLATE_PLACEHOLDER_UNRESOLVED` rather than sending a literal unresolved placeholder to the model), and `builtinPromptTemplateSeeds`/`ensureBuiltinPromptTemplates` (thirteen templates reproducing every existing `assist.*` instruction verbatim as editable text, plus two new ones — `generate_text_block` for the future page-builder "Générer" button and `generate_agent_system_prompt` for the future agent-creation flow — written with the same care as a built-in agent's `identity.md`).
1482
+
1483
+ Every migrated `assist.*` tool constructor now accepts an optional trailing `PromptTemplateStore` argument (`createWritingTools`, `createClassifyTool`, `createModerateTool`, `createFaqTool`, `createSchemaOrgTool`, `createContentChatTool`'s options). Backward compatible: omitting it (or a site whose store has never been seeded) reproduces the exact pre-existing hard-coded instruction, byte for byte — proven by a dedicated non-regression test comparing the seeded-store path against the original inline construction for every migrated tool. A tool's `role`/objectives and the R8 anti-injection rule stay in code, deliberately not migrated — they are the security boundary, not the prompt text an editor should be able to reword from a settings screen.
1484
+
1485
+ `@cogenta/api` gains `createPromptTemplatesRouter` (`/api/prompt-templates`) — `GET` open to any signed-in actor, `POST`/`PATCH`/`DELETE` restricted to `admin`, mirroring `agent-skills-router.ts`'s shape. New `ErrorCode`s (`@cogenta/core`): `PROMPT_TEMPLATE_UNKNOWN` (404), `PROMPT_TEMPLATE_DUPLICATE` (409), `PROMPT_TEMPLATE_BUILTIN_UNDELETABLE` (409), `PROMPT_TEMPLATE_INVALID` (400), `PROMPT_TEMPLATE_PLACEHOLDER_UNRESOLVED` (400).
1486
+
1487
+ `@cogenta/cli`'s `cogenta serve` now builds a `PromptTemplateStore` under `.cogenta/agents-runtime/prompt-templates` (seeded on first boot, idempotent) and threads it through both `buildAssistant` (so the writing-assistant tools resolve their instruction text from it) and `buildAgentRuntime` (which mounts `/api/prompt-templates`) — the same directory, two file-store instances, safe because neither caches across calls.
1488
+
1489
+ The admin's "Prompt Settings" screen (`packages/admin`, private, no changeset) is a new admin-only entry in the AI nav group: list/create/edit/delete a template, with a builtin always editable but never removable.
1490
+ - e01efae: T09-04 (RGPD, audit 2026-09-01) — `exportPersonalData` (`@cogenta/export`) had zero
1491
+ callers anywhere in the codebase; the legal obligation it exists to satisfy was not
1492
+ exerciseable. `@cogenta/api`'s `users-router.ts` gains `GET /api/users/{id}/personal-data`
1493
+ (self-or-admin, the same rule `GET /{id}` itself already follows) — assembles the
1494
+ account, every collection entry it authored (via the same `storeFor` REST/GraphQL/theme
1495
+ rendering already share, now a `UsersRouterOptions` field), and the honest `gaps` array
1496
+ `exportPersonalData` reports for domains this codebase has no store for yet. The export
1497
+ is itself journalled (`user.personal_data_export`, naming whether it was a self-request
1498
+ or an admin acting on a third party). `cogenta serve` wires `storeFor` into the router;
1499
+ the admin gains an "Export my personal data" button on the profile screen (every role,
1500
+ self only) and an "Export the personal data of {{email}}" action per account row on the
1501
+ Users screen (admin, any account). New direct dependency `@cogenta/api` → `@cogenta/export`
1502
+ (R9: reusing an existing, tested assembly function rather than a second one).
1503
+
1504
+ T09-01 — `AuditLog.prune()` (`@cogenta/auth`) has existed since fiche 21 task 5 with no
1505
+ scheduled caller, so an audit log grew without bound on every site regardless of
1506
+ retention intent. `@cogenta/core` gains `security.audit.retainDays` (optional; absent —
1507
+ the default — changes nothing, `0` is the explicit "never purge" opt-out). `cogenta
1508
+ serve` registers a new daily `audit-prune` scheduled task (`Site.tickAuditPrune`) that
1509
+ purges entries older than the configured window and journals the purge itself
1510
+ (`audit.prune`, naming `retainDays`/`cutoff`/`prunedCount`) — a no-op when unconfigured.
1511
+
1512
+ T09-02 — `errorResponse` (`@cogenta/api`) gains a generic `Retry-After` header for any
1513
+ `CogentaError` whose `details.retryAfterMs` names a concrete backoff (only the derived
1514
+ integer ever reaches the wire, never `details` itself). `AUTH_RATE_LIMITED` — thrown by
1515
+ login and forgot-password rate limiting — is the first beneficiary: a 429 that used to
1516
+ say "try again later" in prose now carries a real, pollable `Retry-After`.
1517
+ - 5de237f: Fiche 63 (ADR-0028) — a role's grant on a collection or taxonomy action can
1518
+ now be overridden in the database, applied on the very next request with no
1519
+ deploy cycle. `cogenta.schema.*`'s `permissions` block stays the source of
1520
+ truth for a site that never writes an override; the database is checked
1521
+ first and falls back to the file, never the other way around.
1522
+
1523
+ `@cogenta/core` gains three error codes: `ROLE_PERMISSION_TARGET_UNKNOWN`
1524
+ (404 — an override names a collection/taxonomy the site does not declare),
1525
+ `ROLE_PERMISSION_INVALID` (400 — a malformed override, including `own` on a
1526
+ taxonomy, which has no author) and `ROLE_PERMISSION_EXPORT_INVALID` (a
1527
+ malformed `cogenta roles export` file being read back).
1528
+
1529
+ `@cogenta/schema` gains `createRolePermissionStore` (validates every write
1530
+ by folding the candidate rule into the real `CollectionDefinition`/
1531
+ `TaxonomyDefinition` and reusing `defineCollection`/`defineTaxonomy`
1532
+ unmodified — no second validation logic), `createRolePermissionOverlay` (the
1533
+ synchronous, refreshable read-through cache `PermissionLayer` consults),
1534
+ `ensureRolePermissionTable`/`ROLE_PERMISSIONS_TABLE`, and
1535
+ `serialiseRolePermissionExport`/`parseRolePermissionExport` for freezing the
1536
+ table's state into a versioned JSON file. All additive; contract A
1537
+ (`CollectionDefinition`, `TaxonomyDefinition`, `CollectionPermissions`) is
1538
+ unchanged — the override table lives entirely outside the contract.
1539
+
1540
+ `@cogenta/api`'s `createPermissionLayer` gains an optional
1541
+ `rolePermissionOverrides` option (a `RolePermissionOverrides` from
1542
+ `@cogenta/schema`) — absent behaves byte-for-byte as before. A new router,
1543
+ `createRolePermissionRouter`, serves `GET`/`PUT /api/role-permissions` and
1544
+ `DELETE /api/role-permissions/{targetType}/{targetName}/{action}`,
1545
+ admin-only. `STATUS_BY_CODE` gains the two new HTTP-mapped error codes above.
1546
+
1547
+ `@cogenta/cli` wires the override store and overlay into `cogenta serve`
1548
+ (mounting `/api/role-permissions`, journaling every successful write to the
1549
+ audit log), `cogenta mcp` and `cogenta channels` (each builds its own
1550
+ `PermissionLayer`, so each needed the same wiring — otherwise a permission
1551
+ revoked in production would stay granted to those processes until restart).
1552
+ A new command, `cogenta roles export [--out <path>]`, freezes the table into
1553
+ a file a site can commit to git.
1554
+ - 2c1af5d: Fiche 28 (tâches planifiées): a real scheduled-task registry and its admin
1555
+ screen — task 1 (registry) and task 2 (screen) complete and tested; task 4's
1556
+ concurrency-safe scheduled publication verified. `cogenta serve`'s own
1557
+ wiring of the registry, and the standalone `cogenta cron` command (task 5,
1558
+ for hosts with no permanent process), are **not done** — see below.
1559
+
1560
+ - `@cogenta/schema`'s `ScheduledTaskRegistry` (`createScheduledTaskRegistry`):
1561
+ each task declares a name, description, interval and run function; the
1562
+ registry persists every run (`cogenta_scheduled_task_runs`) — last run,
1563
+ duration, outcome, error — so "did the trash sweep run last night" survives
1564
+ a restart rather than resetting with an in-memory timer. `overdue` is
1565
+ computed from that persisted timestamp (fiche 28's own named pitfall: a
1566
+ detector that lives in memory is blind exactly when a restart makes it
1567
+ matter).
1568
+ - `@cogenta/api`'s `createScheduledTasksRouter` (`GET /api/scheduled-tasks`,
1569
+ `GET .../{name}`, `POST .../{name}/run`, `GET .../queue`,
1570
+ `POST .../queue/{id}/retry`) — admin-only, thin read-through, "run now"
1571
+ never awaits its own audit write so a slow log never hangs the request.
1572
+ - `@cogenta/core`'s `QueueDriver` gains `list()`/`retry()` — the "file" section
1573
+ of the screen, and the way a failed maintenance job (fiche 24's queue) gets
1574
+ retried from the UI instead of a terminal.
1575
+ - `@cogenta/core`'s config gains `scheduler.mode` (`'internal'` |
1576
+ `'external-cron'`) and `backup.*` (interval/keep/dir) — resolved, defaulted,
1577
+ not yet consumed by `cogenta serve` (see below).
1578
+ - Admin: `/scheduled` (new nav entry, admin-only at the route level — R4, the
1579
+ nav link itself is not the gate) — task table with last run/duration/
1580
+ result/next run, an overdue badge, "run now" with a confirmation dialog for
1581
+ a `destructive` task (the trash sweep), a queue section with retry, and a
1582
+ pointer to the dashboard's own scheduled-content list rather than a second
1583
+ copy of it.
1584
+
1585
+ **Genuinely not done, not just deferred quietly**: `cogenta serve` still
1586
+ drives scheduled publication, the trash sweep, the 404-log purge and the
1587
+ audit-integrity check on their own separate `setInterval`s, exactly as
1588
+ before this fiche — none of them are registered with the new
1589
+ `ScheduledTaskRegistry`. The registry and the admin screen above are real
1590
+ and fully tested against a registry populated by hand in their own test
1591
+ suites, but on a running `cogenta serve` today `/scheduled` would show an
1592
+ empty task list, because nothing calls `registry.register()` there yet.
1593
+ Wiring that in, and the `cogenta cron` command (task 5 — the fiche's own
1594
+ §8 leaves "deliver now or later" as an open decision), is real remaining
1595
+ work, not a rename or a config flag. Flagged here rather than left to be
1596
+ discovered later.
1597
+ - 745ebd8: Editorial workflow and owner permission (`schema@2.1`, ADR-0027, fiche 37 + fiche 19
1598
+ task 5).
1599
+
1600
+ Strictly additive — a site that never declares `workflow: { enabled: true }` on a
1601
+ collection, and never uses the `{ roles, own }` permission form, behaves identically
1602
+ to before this release. Proved by a compatibility test: a client reading only
1603
+ `status` gets byte-identical values.
1604
+
1605
+ - `reviewState` (`none`/`pending`/`changes-requested`/`approved`) and
1606
+ `assignedReviewer` join the system fields, orthogonal to `status` — the same design
1607
+ ADR-0022 gave `deletedAt`. `approved` is not `published`: approving authorises,
1608
+ `publish` remains the action that makes an entry public.
1609
+ - A closed, server-side transition table (`submit`/`approve`/`requestChanges`), each
1610
+ gated by its own contract A action (`update` for submit, `publish` for the other
1611
+ two) — never duplicated by a client.
1612
+ - New `ContentStore` methods `submitForReview`/`approveReview`/`requestReviewChanges`/
1613
+ `assignReviewer`, and new REST routes `POST .../submit`, `.../approve`,
1614
+ `.../request-changes`, `.../assign-reviewer` — each its own path, never a second
1615
+ meaning for an existing verb (ADR-0022's own lesson for `purge`).
1616
+ - `CollectionPermissionRule` gains the object form `{ roles, own? }` alongside the
1617
+ plain role-name array, which stays valid. `own: true` scopes every listed role to
1618
+ entries the acting account created; `PermissionLayer.can()`/`.assert()` take an
1619
+ optional `ownerId` to check it.
1620
+ - Reversible, non-destructive migration (`schema21Migration`) adding `review_state`
1621
+ (`not null default 'none'`) and a nullable `assigned_reviewer` to every collection.
1622
+ - Admin: a review queue screen (three tabs — assigned to me / all pending / my
1623
+ submissions — aggregated server-side via a new `GET /api/review`), a pending-count
1624
+ nav badge, and an entry editor sidebar showing workflow state, assigned reviewer,
1625
+ and a contextual action button that replaces the absent Publish button with
1626
+ "Submit for review" for an actor without `publish`.
1627
+
1628
+ Postgres/MySQL/MariaDB integration test files are written
1629
+ (`packages/schema/test/integration/schema-2-1-migration.test.ts`) but not executed
1630
+ this session — Docker unavailable; they skip loudly, naming the missing variable.
1631
+ - 960757d: Fiche 70 (SEO platform parity — AIOSEO/The SEO Framework/MonsterInsights/Site
1632
+ Kit) — four tasks closing the gaps a real research pass found against those
1633
+ four tools, which the earlier SEO fiches (13, 50) never looked at.
1634
+
1635
+ **Task 1 — real-time content score.** `@cogenta/seo` gains `analyseContent`
1636
+ (`content-analysis.ts`): a pure, synchronous TruSEO-style scorer over
1637
+ contract A's rich text — keyword usage in title/description/first sentence,
1638
+ keyword density, sentence length, subheadings, content length. Returns a
1639
+ closed `'red' | 'orange' | 'green'` score, never a numeric percentage. A new
1640
+ conventional field, `seoFocusKeyword`, joins `seoTitle`/`seoDescription`/etc.
1641
+ (contract A untouched). The admin panel keeps its own mirrored copy of the
1642
+ algorithm rather than depending on `@cogenta/seo`/`@cogenta/schema` — the
1643
+ admin is a browser bundle and never takes that dependency.
1644
+
1645
+ **Task 2 — internal link assistant.** `@cogenta/seo` gains
1646
+ `analyseInternalLinks` (`link-assistant.ts`), reusing `@cogenta/schema`'s
1647
+ existing `extractLinks`: reports entries with no inbound link and, for
1648
+ entries sharing title words, up to five link candidates. `@cogenta/api`'s
1649
+ `createSeoRouter` gains `GET /api/seo/link-suggestions?collection=…`, gated
1650
+ by `update` on the named collection (never `admin`) so an editor can run it
1651
+ on whatever they may already write.
1652
+
1653
+ **Task 3 — SEO feature grid.** Four new `seo.*` boolean settings
1654
+ (`contentScoreEnabled`, `linkAssistantEnabled`, `searchVerificationEnabled`,
1655
+ `robotsCustomRulesEnabled`) in `@cogenta/schema`'s site settings registry,
1656
+ all defaulting to `true` so an upgrading site's behaviour is unchanged. The
1657
+ last two are gated centrally inside `@cogenta/cli`'s `readSeoRenderDefaults`,
1658
+ so every consumer (public `robots.txt`, verification meta tags, the
1659
+ diagnostics scan) honours the toggle with no per-call-site duplication.
1660
+
1661
+ **Task 4 — optional Google Search Console connector (ADR-0032).**
1662
+ `@cogenta/seo` gains `search-console.ts`: a fetch-only OAuth client (no
1663
+ `googleapis` SDK) for the authorization URL, token exchange/refresh, and one
1664
+ read-only `searchAnalytics.query` call — structurally incapable of writing
1665
+ anything on the Google side. `@cogenta/schema` gains
1666
+ `createSearchConsoleConnectionStore`: one site-wide connection row,
1667
+ AES-256-GCM at rest via `COGENTA_AUTH_SIGNING_KEY` (same discipline as the
1668
+ LLM provider store), full SQLite/Postgres/MySQL/MariaDB contract suite.
1669
+ `@cogenta/api` gains `createSearchConsoleRouter`
1670
+ (`/api/seo/search-console/*`): `status`/`authorize`/`metrics`/`disconnect`
1671
+ are admin-only; `callback` (Google's own browser redirect target) carries no
1672
+ bearer token by design, proven legitimate instead by an HMAC-signed,
1673
+ ten-minute `state` token keyed by `COGENTA_AUTH_SIGNING_KEY`. `@cogenta/core`
1674
+ gains the `searchConsole` config section (client id/secret, environment-only,
1675
+ refused in the config file like every other secret) and five new error codes
1676
+ (`SEARCH_CONSOLE_NOT_CONFIGURED`/`_NOT_CONNECTED`/`_STATE_INVALID`/
1677
+ `_TOKEN_EXCHANGE_FAILED`/`_QUERY_FAILED`). Absent without both
1678
+ `COGENTA_SEARCH_CONSOLE_CLIENT_ID`/`_CLIENT_SECRET` set — every other SEO
1679
+ feature, including tasks 1-3 above, works identically with or without it
1680
+ (R1/R2), which was the explicit condition the user set when accepting
1681
+ ADR-0032.
1682
+ - 07c0f0a: Fiche 57 (Compétences : dossiers de référence standard) — a skill's
1683
+ `references/`, `scripts/` and `assets/` sub-folders, the standard layout a
1684
+ real Claude Code/Anthropic skill uses, are now created automatically and
1685
+ manageable from the admin. No contract A/B/C/D touched; no ADR required
1686
+ (that would only apply to a future `skill.read_resource` tool, which this
1687
+ fiche deliberately does not add).
1688
+
1689
+ **`@cogenta/agents`**: `AgentSkillStore` gains `listResources`,
1690
+ `addResource` and `removeResource`, plus the exported `SKILL_RESOURCE_DIRS`
1691
+ constant and `SkillResource`/`SkillResourceDir` types.
1692
+ `createFileAgentSkillStore`'s `create()` now also creates the three standard
1693
+ sub-folders, empty, alongside `SKILL.md`/`.meta.json`. Writing or removing a
1694
+ path outside `references/`, `scripts/` or `assets/` — or one that tries to
1695
+ escape the skill's own directory — is refused
1696
+ (`AGENT_SKILL_RESOURCE_INVALID`); a skill created before this fiche, with no
1697
+ sub-folders on disk, lists an empty resource set rather than erroring.
1698
+
1699
+ **`@cogenta/core`**: two new error codes, `AGENT_SKILL_RESOURCE_INVALID` and
1700
+ `AGENT_SKILL_RESOURCE_UNKNOWN`.
1701
+
1702
+ **`@cogenta/api`**: `agent-skills-router.ts` gains `GET`/`POST
1703
+ /api/agent-skills/:id/resources` and `DELETE
1704
+ /api/agent-skills/:id/resources/<path>`, all admin-only like the rest of the
1705
+ router. An upload accepts either a real `multipart/form-data` body (`path`
1706
+ field, `file` part — no base64 inflation for a binary asset) or a JSON body
1707
+ `{ path, content }` with `content` as plain UTF-8 text.
1708
+ `AgentSkillRegistryLike` gains the three matching methods; any other
1709
+ implementer of this interface needs to add them.
1710
+
1711
+ **`@cogenta/cli`**: `agent-runtime.ts`'s `createSkillRegistryAdapter` wires
1712
+ the three new methods straight through to `AgentSkillStore` — no new CLI
1713
+ command or flag.
1714
+
1715
+ **Admin** (not published, `@cogenta/admin`): the Compétences screen's edit
1716
+ row gains a "Fichiers de référence" panel — three lists (Références,
1717
+ Scripts, Gabarits) with upload and remove, using `FormData` uploads directly
1718
+ rather than the `fileToBase64` path `media-client.ts` still uses, since a
1719
+ resource file (an asset image, in particular) should not pay a ~33% base64
1720
+ inflation when a real `multipart/form-data` transport is already wired on
1721
+ the server side.
1722
+
1723
+ Nothing here is loaded into an agent's context automatically — deliberately
1724
+ so, per the fiche's own warning against uncontrolled context growth (R7).
1725
+
1726
+ ### Patch Changes
1727
+
1728
+ - 154a751: Fiche 22 tâche 8 (finitions d'admin) — several small, independently useful
1729
+ changes across the published packages:
1730
+
1731
+ `@cogenta/core`'s `package.json` now declares `"./package.json"` in its
1732
+ `exports` map, so a dependent (`@cogenta/cli`) can resolve its own real
1733
+ installed version through Node's standard ESM resolution instead of a
1734
+ hand-maintained copy. Purely additive; nothing else in the package changes.
1735
+
1736
+ `@cogenta/schema`'s `SITE_SETTINGS_REGISTRY` gains a `navigation` group and
1737
+ four new keys (`navigation.sectionOrder`, `navigation.hiddenSections`,
1738
+ `navigation.itemOrder`, `navigation.hiddenItems`) — site-wide admin sidebar
1739
+ reordering and hiding (e.g. "hide the Commerce section on a portfolio
1740
+ site"), stored the same comma-separated-list way `content.
1741
+ newEntryDefaultBlocks` already is. Additive to the registry; no existing key
1742
+ changes shape or default.
1743
+
1744
+ `@cogenta/api`'s `ShellStatus` (and `createShellStatusRouter`'s
1745
+ `ShellStatusRouterOptions`) gains `cogentaVersion: string` — the installed
1746
+ `@cogenta/core` version, answered to every actor including an anonymous
1747
+ one (never secret), consumed by the admin footer/topbar. A caller that does
1748
+ not pass `cogentaVersion` gets `'0.0.0'` rather than `undefined`.
1749
+
1750
+ `@cogenta/cli` gains `getCogentaVersion()` (`commands/cogenta-version.ts`),
1751
+ resolving `@cogenta/core`'s own `package.json` version through
1752
+ `import.meta.resolve` and caching it. `cogenta serve` now threads this
1753
+ version into `GET /api/shell-status` and, when Cogenta's own branding stays
1754
+ on, into the public site footer next to its existing credit — extending
1755
+ `ThemeRenderOptions`'s `BrandingSettings` with an optional `cogentaVersion`
1756
+ field, never duplicating the branding on/off logic itself.
1757
+
1758
+ `@cogenta/theme-canonical`'s `base.css` gains a small `.cg-site-footer__version`
1759
+ rule for the version text above, and a `gap` on `.cg-site-footer__branding a`
1760
+ so the logo and the version sit apart cleanly — no structural change to the
1761
+ footer markup beyond the one optional `<span>`.
1762
+
1763
+ ## 0.4.0
1764
+
1765
+ ### Minor Changes
1766
+
1767
+ - [`d72b40f`](https://github.com/cogenta-cms/cogenta/commit/d72b40f64ab5b98985a22d9daae34796a4638f45) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Self-hosted, cookie-free page-view analytics — the one CMS feature category
1768
+ the audit found completely missing. No third party, no cookie, no personal
1769
+ data ever stored, consistent with R1 (no dure dependency on external
1770
+ infrastructure) and the project's privacy stance.
1771
+
1772
+ **New package `@cogenta/analytics`.** One table (`cogenta_analytics_events`):
1773
+ timestamp, page path, referring **domain only** (never the full referrer
1774
+ URL), a device category reduced from the User-Agent (`desktop`/`mobile`/
1775
+ `tablet`/`other`, never the raw string), and a **daily-salted session hash**
1776
+ — never an IP address, never a cookie. The salt (`cogenta_analytics_daily_salts`)
1777
+ is minted once per UTC day and rotates every day, so
1778
+ `sha256(salt|ip|device)` for the same real visitor is a *different*, unrelated
1779
+ value on every new day: nothing in the stored data can link two days of the
1780
+ same visitor's traffic, even with full database access, because reproducing
1781
+ yesterday's hash needs yesterday's IP, which was never written down. The IP
1782
+ address and the full User-Agent are used only as transient inputs to that
1783
+ hash and to the device classifier — neither is ever persisted. A dedicated
1784
+ privacy test suite (`test/privacy.test.ts`) inspects the actual stored
1785
+ columns, not just the public types, to prove this. `createAnalyticsStore`
1786
+ aggregates views by day, top pages, top referring domains and device
1787
+ breakdown; a same-session rate limit (60 events/minute) drops abusive
1788
+ traffic silently rather than erroring.
1789
+
1790
+ **`@cogenta/core`** gains one error code, `ANALYTICS_SALT_UNAVAILABLE`
1791
+ (an internal race-recovery failure, not expected in normal operation).
1792
+
1793
+ **`@cogenta/api`** gains `createAnalyticsRouter`: `GET /api/analytics/beacon`
1794
+ (public, records one event, always answers `204` even on a malformed or
1795
+ rate-limited request — a public collection endpoint must never break page
1796
+ rendering) and `GET /api/analytics/summary` (`admin`-only, `?days=` window).
1797
+
1798
+ **`@cogenta/cli`** wires both into `cogenta serve` and injects the collection
1799
+ tag into every rendered page. The tag is an invisible `<img>` pixel, not a
1800
+ `<script>`: the theme's rendered output already carries a hard "zero
1801
+ executable client JavaScript" property (enforced by a `serve.test.ts`
1802
+ assertion), so a script reading `document.referrer` was not an option. The
1803
+ referrer is instead read **server-side**, from the `Referer` header of the
1804
+ request that is rendering the page, and baked straight into the pixel's URL
1805
+ — no client code needed to capture it. The page builder's live-preview
1806
+ render includes the same pixel (rather than omitting it) specifically to
1807
+ keep its `<body>` byte-identical to the published page's, the invariant
1808
+ `theme-render-fidelity` depends on.
1809
+
1810
+ The admin gains a full `/analytics` dashboard (hand-built SVG bar chart, no
1811
+ charting dependency — R9) and a "views this week" widget on the main
1812
+ dashboard, both `admin`-only like every other traffic-shaped view in the
1813
+ admin.
1814
+
1815
+ - [`4eda357`](https://github.com/cogenta-cms/cogenta/commit/4eda35754f55484e12028707e4f54aaaccc188d2) Thanks [@georgesmomo](https://github.com/georgesmomo)! - API keys — machine-to-machine authentication, absent until now (L13 task 8).
1816
+ A script or integration had no way to authenticate against the REST/GraphQL
1817
+ API short of signing in as a human account and keeping its session alive.
1818
+
1819
+ `@cogenta/core` gains four error codes: `API_KEY_INVALID`, `API_KEY_REVOKED`,
1820
+ `API_KEY_EXPIRED`, `API_KEY_NOT_FOUND`.
1821
+
1822
+ `@cogenta/auth` gains `createApiKeyStore`, backed by a new
1823
+ `cogenta_api_keys` table that `ensureAuthTables` creates like the others. A
1824
+ key is `cogenta_sk_` followed by 256 bits of randomness, generated once,
1825
+ returned once, and never stored — only its SHA-256 hash is, looked up by
1826
+ that hash exactly the way `sessions.ts` looks up a session token. It is
1827
+ hashed fast rather than with scrypt on purpose: scrypt's cost defends a
1828
+ low-entropy, human-chosen secret against guessing, and a generated key has
1829
+ no such weakness to defend — the same reasoning that already applies to a
1830
+ session token.
1831
+
1832
+ A key carries an explicit `scope`: an open set of role names, exactly like a
1833
+ user's `roles`, chosen once at creation and never derived from the account
1834
+ that minted it. `AuthStore` gains `apiKeys` alongside `users`/`sessions`.
1835
+
1836
+ This changeset lands the store only. `@cogenta/api`'s `resolveActor` and the
1837
+ `/api/api-keys` admin router that mint and revoke keys land in a companion
1838
+ changeset for `@cogenta/api`/`@cogenta/cli`/`@cogenta/admin`.
1839
+
1840
+ - [`206b4cd`](https://github.com/cogenta-cms/cogenta/commit/206b4cd12df7d3a2a5831029b5f0ef726e7fd84d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Completes the admin surface of contract E (ADR-0024) beyond its MVP: multiple
1841
+ variants per product, coupons, invoices and subscriptions are now all
1842
+ reachable from a real HTTP admin, not just the backend that already carried
1843
+ them.
1844
+
1845
+ `@cogenta/commerce`'s `createCommerceAdminRouter` gains: `DELETE
1846
+ /variants/{id}` (a product's variant list was previously append-only from the
1847
+ admin's point of view); `GET`/`POST /coupons` and `POST
1848
+ /coupons/{code}/deactivate`; `GET`/`POST /subscriptions` and the
1849
+ `pause`/`resume`/`cancel` actions (absent when the caller does not wire a
1850
+ `SubscriptionStore` — a site with no `commerceSubscriptions` store answers
1851
+ 404, never a crash); and `GET /orders/{id}/invoice` plus `GET
1852
+ /orders/{id}/invoice/pdf`, the read side of an invoice-issuing route that
1853
+ existed but could previously only be written to, never read back. The PDF
1854
+ route answers with a raw `Uint8Array` body — the one response in this router
1855
+ that is not JSON — and the Node transport (`cogenta serve`) now checks for
1856
+ that shape before deciding whether to `JSON.stringify` or stream bytes with
1857
+ `content-type: application/pdf`.
1858
+
1859
+ `@cogenta/core` gains an optional `billing` config section (legal name,
1860
+ address, tax id, footer) — nothing here is a secret, rule R7 does not apply,
1861
+ a legal name is meant to be printed. Its absence is a real, first-class state:
1862
+ `cogenta serve` only builds an `InvoiceStore` and only accepts `POST
1863
+ /orders/{id}/invoice` once a site has filled this in, because an invoice with
1864
+ a made-up seller address is worse than no invoicing feature at all.
1865
+
1866
+ `@cogenta/cli` wires `createSubscriptionStore` and the conditional
1867
+ `createInvoiceStore` into `assembleSite`, passes `coupons`/`subscriptions`/
1868
+ `invoices` into the admin router (previously only `catalog`/`orders`/
1869
+ `customers`/`payments` were threaded through, silently dropping the coupon
1870
+ store `cogenta serve` already built), and adds the PDF passthrough above.
1871
+
1872
+ The admin (`@cogenta/admin`, private, no changeset) gets the screens this
1873
+ backend work makes possible: a real variant list per product (add, edit,
1874
+ remove, price and stock each independently, `commerce.catalog.write`-gated)
1875
+ replacing the one-variant-per-product MVP; `/commerce/coupons` (create by
1876
+ code/kind/value/validity window/redemption limit, deactivate); `/commerce/
1877
+ subscriptions` (list by status, cancel — creation is deliberately absent,
1878
+ since a subscription is created at checkout, not from the back office); and
1879
+ an "issue invoice" / download-PDF pair on the order detail screen. All money
1880
+ is entered and displayed through the existing `commerce/money.ts` conversion
1881
+ at the edges — every request on the wire still carries `priceMinor`, never a
1882
+ float.
1883
+
1884
+ Proven end to end in `packages/cli/test/serve-commerce.test.ts`, against a
1885
+ real HTTP server and a real SQLite file: a second variant added and removed
1886
+ through the router; a coupon created, listed and deactivated, and refused for
1887
+ a role with only `commerce.read`; a paid order invoiced, the invoice read
1888
+ back by the same route the admin polls, and its PDF downloaded and checked
1889
+ for the format's own magic bytes (`%PDF-`) rather than merely a 200 status; a
1890
+ site with no `billing` configured answering `COMMERCE_INVOICE_NOT_FOUND`
1891
+ instead of issuing a document with a fabricated seller address; and a
1892
+ subscription seeded the way checkout would seed one, listed and cancelled
1893
+ through the real admin API.
1894
+
1895
+ - [`03d1327`](https://github.com/cogenta-cms/cogenta/commit/03d13277224c5abd011d15e19c8f9ec67ef40c27) Thanks [@georgesmomo](https://github.com/georgesmomo)! - The other half of password reset (`.changeset/auth-password-reset.md`,
1896
+ L13 task 6): that changeset built the store and the terminal command and
1897
+ said plainly "no admin route can receive a reset click yet". This is that
1898
+ route, and the screen behind it.
1899
+
1900
+ `@cogenta/auth`'s `AuthStore` gains a `resets` field — the
1901
+ `PasswordResetStore` `createPasswordResetStore` already built, now wired
1902
+ into the object every caller already holds, the same way `rateLimit` and
1903
+ `sessions` are.
1904
+
1905
+ `@cogenta/api`'s `createAuthRouter` gains two routes. `POST
1906
+ /api/auth/forgot-password` accepts an email and answers with the **exact
1907
+ same response** whether or not an account exists for it — the line this
1908
+ route exists to never cross is account enumeration, and every branch of its
1909
+ handler (an existing account, a disabled one, a non-existent one) returns
1910
+ byte-identical bodies. It rate-limits by the submitted email, before the
1911
+ account lookup, on the same subject either way, the same posture
1912
+ `loginAttempts` already applies to a wrong password. Only a real, active
1913
+ account gets a token issued, delivered through a new optional
1914
+ `onForgotPassword` callback rather than a hard dependency on
1915
+ `@cogenta/channels` (R9) — the router itself never sends mail. `POST
1916
+ /api/auth/reset-password` redeems the token, sets the new password (same
1917
+ 12-character floor as the self-service password-change route, now shared
1918
+ from a new `password-policy.ts` instead of duplicated), and revokes every
1919
+ existing session, exactly like `cogenta users reset-password --token`
1920
+ already does. A new error code, `AUTH_RESET_TOKEN_INVALID` (400), names an
1921
+ invalid, expired or already-used token — unlike `forgot-password`, this
1922
+ route's refusal is allowed to say why, since the secret here is the token
1923
+ itself, not whether an email exists.
1924
+
1925
+ `@cogenta/cli` factors the mail-sending half of `cogenta users
1926
+ reset-password --email` out of `commands/users.ts` into a new shared
1927
+ `reset-mail.ts`, so `cogenta serve` can wire the identical wording (now with
1928
+ an optional link to the admin's reset screen instead of the terminal
1929
+ command) into `onForgotPassword` without a second copy of it. `runServe`
1930
+ passes it to `createAuthRouter` unconditionally: the token is still issued
1931
+ and thrown away unsent when no site's mail is configured to go anywhere
1932
+ useful, since the HTTP response must never depend on whether the mail could
1933
+ be delivered.
1934
+
1935
+ `@cogenta/admin` (private, no changeset) gains the two screens this needed:
1936
+ "forgot password" on `/forgot-password`, linked from the sign-in screen, and
1937
+ "reset password" on `/reset-password?token=…`, the link the mail sends. Both
1938
+ are public routes, like `/login`. The user-management screen's role editor
1939
+ also moves off a raw comma-separated text field: four standard role names
1940
+ (`admin`/`editor`/`author`/`contributor`) are now offered as checkboxes,
1941
+ alongside any role a site's accounts already use, plus a free-text field for
1942
+ a role of the site's own — a UX convention only, not a contract A change
1943
+ (a role is still an arbitrary string as far as the server and the five
1944
+ permission actions are concerned).
1945
+
1946
+ - [`174b521`](https://github.com/cogenta-cms/cogenta/commit/174b521e9bca3b783e06ac8aa3dff6e0ded58aa5) Thanks [@georgesmomo](https://github.com/georgesmomo)! - L17 tasks 1-4: a local/embedded marketplace catalog with one-click install,
1947
+ scoped deliberately without a real remote registry service — L13 task 8 (API
1948
+ keys), which the lot names as the dependency for a distant marketplace, was
1949
+ never built in this repository.
1950
+
1951
+ `@cogenta/plugins` gains `createMarketplaceCatalog` (an in-memory, searchable,
1952
+ category-filterable directory the caller assembles — not a fetch to any
1953
+ external host) and `createMarketplaceInstaller`, plus `loadMarketplacePlugin`:
1954
+ a stricter sibling of `loadPlugin` that treats every reference as
1955
+ `registry`-trust unconditionally, so a marketplace item never takes the
1956
+ `local`/dev-mode shortcut that would otherwise skip signature verification for
1957
+ a catalog entry that happens to point at a local directory.
1958
+
1959
+ **The one line the whole task hinges on**: `MarketplaceInstaller.install`
1960
+ always calls `loadMarketplacePlugin`, which always verifies signature against
1961
+ the trusted registry keys — there is no parameter anywhere in this path that
1962
+ can skip that call, and a missing or invalid signature throws before anything
1963
+ is persisted. Only `kind: 'plugin'` installs for now (`MARKETPLACE_KIND_UNSUPPORTED`
1964
+ otherwise) — themes/skins/skills keep using their own existing registries
1965
+ (`createThemeRegistry`/`createSkinGallery`/`createSkillRegistry`).
1966
+
1967
+ `MarketplaceInstaller.update` re-verifies the signature of the new reference,
1968
+ computes newly-declared capabilities against the plugin's existing grants
1969
+ (`detectCapabilitiesNeedingApproval`, unchanged from L7), and refuses
1970
+ (`MARKETPLACE_UPDATE_REQUIRES_APPROVAL`) unless the caller explicitly passes
1971
+ `confirmPendingPermissions: true` — and even then, no capability is
1972
+ auto-granted; `PluginGrantStore.grant` stays a separate, explicit step.
1973
+
1974
+ `@cogenta/api` gains `createMarketplaceRouter` (`/api/marketplace/items`,
1975
+ admin-only, structurally typed against `@cogenta/plugins` rather than
1976
+ depending on it at runtime) with list/detail/install/update/uninstall routes.
1977
+ The detail route reuses `describeCapability` (L7 task 7) so a plugin's
1978
+ requested capabilities read in plain language, the same sentences the
1979
+ existing permission-review screen already renders.
1980
+
1981
+ `@cogenta/core` gains the error codes this needs:
1982
+ `MARKETPLACE_ITEM_NOT_FOUND`, `MARKETPLACE_KIND_UNSUPPORTED`,
1983
+ `MARKETPLACE_ALREADY_INSTALLED`, `MARKETPLACE_NOT_INSTALLED`,
1984
+ `MARKETPLACE_UPDATE_REQUIRES_APPROVAL` — and `PLUGIN_SIGNATURE_MISSING`/
1985
+ `PLUGIN_SIGNATURE_INVALID`/`PLUGIN_SOURCE_NOT_FOUND`/`PLUGIN_MANIFEST_INVALID`
1986
+ (existing L7 codes, never before mapped to an HTTP status because no REST
1987
+ route threw them until now) gain entries in `statusFor` (422/404/422).
1988
+
1989
+ **Not done, by explicit scope cut under a hard deadline**: `cogenta serve`
1990
+ does not yet mount this router, so the catalog/installer above are complete,
1991
+ independently tested, and ready to wire, but not yet reachable over HTTP from
1992
+ a running site — the same honest gap the codebase already tolerates elsewhere
1993
+ (`cogenta build`/`deploy`/`theme`, L9 task 9) rather than a stub. Bundled
1994
+ updates across multiple items and the commercial (paid extension) track named
1995
+ in the lot doc are both out of scope for this pass.
1996
+
1997
+ - [`b37e51c`](https://github.com/cogenta-cms/cogenta/commit/b37e51cea79fc8d3070d5c741a8415192985d9ff) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Give the redirect table, HTTP security and outbound webhooks a real admin
1998
+ screen (audit follow-up to L10 tasks 2/6 and L14 task 1)
1999
+
2000
+ Three backend pieces existed and were fully wired into `cogenta serve` with
2001
+ no way to reach them from a browser.
2002
+
2003
+ - `@cogenta/core` gains the `REDIRECT_UNKNOWN` error code, for a `DELETE` on a
2004
+ redirect that does not exist.
2005
+ - `@cogenta/api` gains `createRedirectRouter` (`GET`/`POST`/`DELETE
2006
+ /api/redirects`) and `createOpsStatusRouter` (`GET /api/security-status`,
2007
+ `GET /api/webhooks-status`). Both are admin-only on every method, including
2008
+ `GET`: a redirect table and a site's CORS/CSP/HSTS configuration are
2009
+ routing and hardening decisions, not content, so neither has a reader role
2010
+ the way a taxonomy or a menu does. Loop and self-redirect refusal is
2011
+ entirely `RedirectStore`'s own job (`CONTENT_REDIRECT_LOOP`,
2012
+ `CONTENT_ROUTE_INVALID`), surfaced here as a proper 409/400 instead of a
2013
+ 500.
2014
+ - `cogenta serve` mounts all three at `/api/redirects`, `/api/security-status`
2015
+ and `/api/webhooks-status`, and `@cogenta/admin` gains three screens:
2016
+ `/redirects` (full CRUD) and `/ops-settings` (`security` and `webhooks`,
2017
+ **read-only**).
2018
+
2019
+ The security and webhooks screens are read-only by design, not by omission.
2020
+ Both settings live in the site's `cogenta.config.mjs` — versioned in git,
2021
+ deployed with the code that depends on it (a CSP that allows a script host
2022
+ has to travel with the deploy that added the script). Letting the admin edit
2023
+ them would create a second source of truth that disagrees with the file the
2024
+ moment either one changes without the other, which is a bigger architecture
2025
+ change than this audit's scope. The screens instead mirror exactly what the
2026
+ running process is enforcing on every request.
2027
+
2028
+ No delivery history is shown for webhooks: none is persisted anywhere today
2029
+ (`WebhookEventSender.send` only ever returns a per-call result to log). The
2030
+ screen says so rather than inventing one.
2031
+
2032
+ ## 0.3.0
2033
+
2034
+ ### Minor Changes
2035
+
2036
+ - [`552645e`](https://github.com/cogenta-cms/cogenta/commit/552645e039b8c8c4f5340d065ea2f4a552950815) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Advanced AI (L18): a writing assistant, a `vector` driver, semantic search,
2037
+ RAG chat with citations, classification/duplicate detection/moderation, and
2038
+ FAQ/Schema.org drafting. **Nothing here is on a required path** — a site with
2039
+ no AI provider configured behaves exactly as before, and the whole feature set
2040
+ disappears from the UI rather than failing (R2).
2041
+
2042
+ - **`@cogenta/agents`** gains the `vector` driver need the architecture
2043
+ document has named since L0 and nothing implemented: `VectorStore` with three
2044
+ drivers behind the existing `createDriverRegistry` — `pgvector` (optimal),
2045
+ `file` (degraded, survives a restart) and `memory` (degraded, always
2046
+ available). One contract suite runs against all three; pgvector's run is an
2047
+ integration test that skips loudly without `COGENTA_TEST_POSTGRES_URL`.
2048
+ Nothing re-implements cosine similarity: L4's `vectorRank` does the ranking
2049
+ everywhere, and all three drivers return the same number.
2050
+
2051
+ `createSemanticSearch` fuses the vector half with L10's full-text index by
2052
+ RRF — **beside it, never instead of it**: pure vector search misses
2053
+ exact-keyword queries, which is the failure the architecture document warns
2054
+ about at line 190.
2055
+
2056
+ Fifteen Contract C tools, all `sideEffects: false`, every output carrying
2057
+ `applied: false` as a **literal** so an assistant tool's type cannot say it
2058
+ changed anything (R6). Eight writing tools (rewrite, proofread, summarise,
2059
+ translate, meta description, titles, tags, alt text), `assist.generate_image`
2060
+ behind a two-vendor image provider driver (OpenAI, Stability), `assist.chat`
2061
+ (RAG with citations), `assist.classify`/`assist.find_duplicates`/
2062
+ `assist.moderate`, and `assist.faq_draft`/`assist.schema_org_draft`.
2063
+
2064
+ Three properties worth knowing:
2065
+ - **Citations come from retrieval, not from the model.** The model names
2066
+ 1-based indices into the passages it was shown; this code maps them back to
2067
+ what the retriever returned, and an invented index resolves to nothing. A
2068
+ chat answer can never cite a page that was not retrieved.
2069
+ - **Moderation and duplicate detection can recommend `none` or `review`, and
2070
+ nothing else.** The union has no destructive member, so no answer —
2071
+ however jailbroken — describes a deletion.
2072
+ - **`assist.find_duplicates` needs no AI provider at all.** It embeds with
2073
+ the site's `EmbeddingProvider`, which by default is the local hashing one:
2074
+ no key, no service, no model download.
2075
+
2076
+ - **`@cogenta/core`** gains an `imageGeneration` config section
2077
+ (`COGENTA_IMAGE_PROVIDER`/`_MODEL`/`_BASE_URL`, key in `COGENTA_IMAGE_API_KEY`
2078
+ and refused in the config file like every other secret), a `vector` section
2079
+ (`driver`/`path`/`table` — dimensions stay on `embeddings`, never duplicated),
2080
+ and the error codes `VECTOR_DIMENSION_MISMATCH`, `VECTOR_STORE_FAILED`,
2081
+ `ASSIST_UNAVAILABLE`, `ASSIST_RESPONSE_INVALID`.
2082
+
2083
+ - **`@cogenta/api`** gains `createAssistantRouter` — `GET /api/assistant` and
2084
+ `POST /api/assistant/run`. The `GET` answers **200 with
2085
+ `{available: false, tools: []}`** on a site with no provider, which is what
2086
+ lets a client render nothing instead of handling an error. The permission
2087
+ gate is the route's, not the tools' (R4): an actor may use the assistant when
2088
+ they may edit content somewhere, and an anonymous caller is refused before any
2089
+ provider is contacted, so an unauthenticated request can never spend the
2090
+ site's AI budget. The route also refuses any tool declaring a side effect,
2091
+ even though none does.
2092
+
2093
+ - **`@cogenta/cli`** wires all of it into `cogenta serve`: providers built from
2094
+ the config, the vector store selected through the registry, the content stores
2095
+ wrapped so a publish updates the embedding index the same way it already
2096
+ updates the full-text one, and `/api/assistant` mounted on every site. Every
2097
+ piece degrades to "off" with a log line rather than stopping the site: an
2098
+ unknown provider name, a missing API key, an unavailable vector store and an
2099
+ embeddings provider with no adapter yet are four warnings, not four crashes.
2100
+
2101
+ **Migration**: none. Every new configuration section is optional, and a site
2102
+ that adds none behaves exactly as it did before.
2103
+
2104
+ - [`8b561d1`](https://github.com/cogenta-cms/cogenta/commit/8b561d1ba735eb2b42c27725f67faf64e53866e5) Thanks [@georgesmomo](https://github.com/georgesmomo)! - E-commerce (L15), as a new package `@cogenta/commerce` on a **new contract E**
2105
+ rather than an extension of contract A.
2106
+
2107
+ The decision is proposed in `ADR-DRAFT-commerce.md` at the repo root and is
2108
+ **not yet acted** — it needs a human to accept it before it goes into
2109
+ `docs/03-decisions.md`. The implementation assumes it. In one line: three of
2110
+ contract A's own decisions make an order a bad content entry. ADR-0014 would
2111
+ fork one order per language; ADR-0022 has just made every content entry
2112
+ restorable from the trash, and an order is not; and versioning drafts have no
2113
+ meaning for a sale. The product's *editorial* face stays firmly in contract A
2114
+ through an optional `contentRef`, so a catalogue keeps rich text, blocks, SEO,
2115
+ translations and scheduling for free. Contract A does not move: it stays at
2116
+ `schema@2.0`, and a site that sells nothing never creates a commerce table.
2117
+
2118
+ **Money is an integer of minor units, everywhere.** The three mandatory
2119
+ dialects do not agree on decimals — SQLite has only `REAL`, a binary float — so
2120
+ a decimal column would mean something subtly different on one of the three
2121
+ supported databases. Rates are basis points for the same reason. Amounts are
2122
+ `bigint` columns, and every read goes through a decoder, because `pg` hands
2123
+ `int8` back as a *string*: a price read as `"1999"` and added to another is
2124
+ `"19991999"`, a bug that would appear only on Postgres and only in production.
2125
+
2126
+ **Stock cannot go negative.** `takeStock` runs one immediate transaction and
2127
+ lowers each line with `update … set on_hand = on_hand - n where id = ? and
2128
+ on_hand >= n`, reading `rowsAffected` — the same idiom that makes a password
2129
+ reset token single use. Repeated variants in one basket are summed first, or
2130
+ two lines of two would each pass against a stock of three. The concurrency test
2131
+ is a real race against a SQLite *file* with two independent connections
2132
+ (`:memory:` gives two unrelated databases and would prove nothing), and it
2133
+ carries a control that re-implements the naive read-then-write and asserts it
2134
+ *does* oversell.
2135
+
2136
+ **Placing an order is one transaction**: stock taken, coupon redemption
2137
+ claimed, order and lines written, cart closed, first history event recorded.
2138
+ Any failure and none of it happened.
2139
+
2140
+ **Payment is interface plus two implementations**, like cache, queue and
2141
+ storage (R1). Stripe is `optimal`, written against the REST API with `fetch`
2142
+ and no `stripe` dependency, with real webhook signature verification
2143
+ (timing-safe, every `v1` candidate, 5-minute freshness window). Bank transfer
2144
+ is `degraded` and is **not a stub** — plenty of businesses are paid that way
2145
+ and nothing else; the difference is who confirms the money arrived. The whole
2146
+ checkout, the whole subscription biller and the whole invoice path are tested
2147
+ end to end with no API key, URL or network configured anywhere (R2).
2148
+
2149
+ **Invoice numbers are gapless and never reused**, claimed by a compare-and-set
2150
+ inside the transaction that writes the invoice, so a rolled-back invoice does
2151
+ not burn a number and two invoices issued in the same millisecond get
2152
+ consecutive ones. A `count(*) + 1` would hand out duplicates under any
2153
+ concurrency and re-issue a number a deleted row used to hold. The PDF is
2154
+ generated with zero dependencies (R9/R10) and is deterministic: the same
2155
+ invoice regenerated years later is byte-identical, because it renders from a
2156
+ frozen snapshot and never reads a clock.
2157
+
2158
+ Also: tax rules resolved by specificity rather than insertion order; shipping
2159
+ methods with an optional carrier driver that falls back to the stored rate when
2160
+ the courier's API is down; coupons with three kinds and a redemption count
2161
+ claimed the same way stock is; subscriptions whose month arithmetic puts 31
2162
+ January + 1 month on 28 February rather than 3 March, and which bill through
2163
+ the same orders, payments and invoices as everything else.
2164
+
2165
+ `@cogenta/core` gains the `COMMERCE_*` error codes (a minor bump: adding a code
2166
+ is additive, and nothing existing changed meaning).
2167
+
2168
+ **Not in this release**, and deliberately so: no admin React screens (the
2169
+ back office is a transport-free router with its own permission vocabulary,
2170
+ tested by role — the UI belongs with L11's design system), no storefront
2171
+ blocks, and no Stripe integration test against a real sandbox (it is written
2172
+ and skips loudly without `COGENTA_TEST_STRIPE_SECRET_KEY`).
2173
+
2174
+ - [`182ef48`](https://github.com/cogenta-cms/cogenta/commit/182ef48d97e2757e7b1404dc407327f53ed377dd) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Document text extraction, as a contract C tool (L19 task 1). `@cogenta/agents`
2175
+ gains `document.extract_text` and the `extractDocumentText` function behind it:
2176
+ PDF, DOCX, Markdown and plain text in, plain text out. Format detection reads
2177
+ the bytes rather than the extension, since a brief emailed as `.pdf` is often
2178
+ really a `.docx`.
2179
+
2180
+ No new dependency, on purpose (R9/R10). A `.docx` is a ZIP whose
2181
+ `word/document.xml` holds the body, and `node:zlib` already opens it — the
2182
+ ~120 lines of central-directory reading here replace a callback-era unzip
2183
+ library. The PDF reader walks content streams and their text-showing
2184
+ operators (`Tj`, `TJ`, `'`, `"`) instead of pulling in `pdf.js` through
2185
+ `pdf-parse`.
2186
+
2187
+ It refuses rather than guesses, which is the part that matters downstream: a
2188
+ scan with no text layer is `DOCUMENT_NO_TEXT_LAYER`, an encrypted PDF says so,
2189
+ a legacy binary `.doc` is named as such, and — calibrated against real
2190
+ LaTeX-exported specifications — a PDF whose text layer is subset-font glyph
2191
+ indices is refused too, rather than passing mojibake on to an agent that would
2192
+ happily build a confident, entirely invented site plan from it. Footnotes and
2193
+ endnotes of a `.docx` are appended rather than dropped, and an embedded image
2194
+ produces a warning saying any requirement written inside it was not read.
2195
+
2196
+ `@cogenta/core` gains the error codes this needs
2197
+ (`DOCUMENT_FORMAT_UNSUPPORTED`, `DOCUMENT_TOO_LARGE`,
2198
+ `DOCUMENT_EXTRACTION_FAILED`, `DOCUMENT_NO_TEXT_LAYER`) plus the ones L19's
2199
+ later tasks use.
2200
+
2201
+ Contract C moves to `tools@1.1`: the permission taxonomy gains
2202
+ `document.extract`. No existing tool signature changes.
2203
+
2204
+ - [`755201d`](https://github.com/cogenta-cms/cogenta/commit/755201d55fd8c04ba2794a03797696769b59f6cc) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Send a real signed webhook when content is published (L14 task 1)
2205
+
2206
+ The signed outbound webhook channel has existed since L6 and nothing ever
2207
+ called it. It is now connected to the content lifecycle.
2208
+
2209
+ - `@cogenta/channels` gains `createWebhookEventSender`, which POSTs a
2210
+ structured `{ event, occurredAt, data }` envelope to every configured
2211
+ endpoint. It reuses `signOutgoingWebhook` and the existing
2212
+ `X-Cogenta-Timestamp` / `X-Cogenta-Signature` headers **verbatim**, so a
2213
+ receiver verifies an event with `verifyIncomingWebhook` exactly as it
2214
+ verifies a message — there is no second signing path. It never throws: a
2215
+ failed delivery comes back as a result to log, so an editor's publish is
2216
+ never lost to somebody else's downtime.
2217
+ - `@cogenta/schema` gains `withLifecycleEvents`, a `ContentStore` decorator in
2218
+ the same shape as `withSearchIndexing`. It emits `content.publish` (from
2219
+ `publish()`, and from `create()` with a published status),
2220
+ `content.unpublish` and `content.delete`, each carrying the entry's
2221
+ identity, status, timestamps and its real route path from `buildPath`.
2222
+ Draft edits emit nothing. The event body never carries the content itself.
2223
+ - `@cogenta/core` gains a `webhooks.endpoints` config section. The signing
2224
+ secret is environment-only (`COGENTA_WEBHOOK_SECRET`, rule R7); endpoints
2225
+ configured without it disable delivery with a startup warning rather than
2226
+ falling back to unsigned requests.
2227
+ - `cogenta serve` wires the two together, outermost of all store decorators so
2228
+ an event only describes a write that really landed.
2229
+
2230
+ Proven end to end by a suite that publishes over real HTTP and verifies the
2231
+ signature on the bytes a real `node:http` receiver got off the socket.
2232
+
2233
+ - [`551a06c`](https://github.com/cogenta-cms/cogenta/commit/551a06c2e58bb4119618e5502dfcae4bb024b7d4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Serve a site's own page for an unmatched URL (L14 task 2)
2234
+
2235
+ `cogenta serve` answered every unmatched public URL with a bare JSON error.
2236
+ It now renders the site's own 404 page instead, with a real 404 status.
2237
+
2238
+ The 404 body is an ordinary published entry at `site.notFoundPath` (`/404` by
2239
+ default, overridable in `cogenta.config` or via `COGENTA_SITE_NOT_FOUND_PATH`)
2240
+ — editable in the admin like any other page, and rendered by exactly the same
2241
+ function, through exactly the same permission-checked gateway, as every other
2242
+ page. So a draft 404 page is not shown to the public, and a site that has not
2243
+ written one still gets the plain refusal it got before. The lookup happens at
2244
+ most once per request: the 404 path itself is never re-resolved.
2245
+
2246
+ - [`87bae8d`](https://github.com/cogenta-cms/cogenta/commit/87bae8dd4cc08261f3d5ba83947fa2ad77b0b826) Thanks [@georgesmomo](https://github.com/georgesmomo)! - **Breaking: contract A moves to `schema@2.0`** (ADR-0022) — the trash and native
2247
+ taxonomies, in one version bump with one migration.
2248
+
2249
+ ### `delete()` changed meaning without changing signature
2250
+
2251
+ `ContentStore.delete()` no longer issues a `DELETE`. It writes the new system
2252
+ field `deletedAt` and leaves every row where it was — versions, blocks, join
2253
+ rows, and the `translation_of` of any translation. Two new methods complete it:
2254
+
2255
+ - `purge(id)` is the real `DELETE`, i.e. what `delete()` used to do;
2256
+ - `untrash(id)` takes an entry back out, with the status it went in with;
2257
+ - `purgeExpired()` removes what has outlived the collection's `trash.retainDays`.
2258
+
2259
+ **How to migrate.** Code that called `delete()` to genuinely destroy a row — an
2260
+ import script that cleans up, a test that resets — must now call `purge()`.
2261
+ Nothing will fail loudly if you do not: the call still succeeds and simply
2262
+ leaves the row behind, which is the worst kind of break and the reason it is
2263
+ called out first here. `trash: false` on a collection restores the old
2264
+ behaviour outright.
2265
+
2266
+ ### Every read now filters the trash by default
2267
+
2268
+ `read`, `list`, `translations`, `resolveLocale` and `history` exclude trashed
2269
+ entries unless the caller passes `trashed: 'include' | 'only'`. That direction
2270
+ is deliberate: a renderer, a sitemap or a headless client written against 1.0
2271
+ keeps serving live content with no change at all.
2272
+
2273
+ ### `restrict` is now enforced in application code
2274
+
2275
+ Trashing is an `UPDATE`, so a foreign key can no longer refuse it. `delete()`
2276
+ checks referring entries itself and names what blocks ("2 entries of
2277
+ \"article\" still reference it"); `purge()` runs the same check so both paths
2278
+ give the same sentence. This needs the sibling collections, so
2279
+ `createContentStore` takes a new optional `siblings` option — **pass it**. Left
2280
+ out, only self-references are checked; nothing is destroyed, since `purge()`
2281
+ still meets the real foreign key, but a trash that should have been refused
2282
+ will be allowed.
2283
+
2284
+ `withReadOnlyStore` refuses `delete`, `untrash`, `purge` and `purgeExpired`.
2285
+
2286
+ ### Native taxonomies
2287
+
2288
+ `defineTaxonomy()` is a second top-level declarable object beside
2289
+ `defineCollection()`, and `f.taxonomy({ of, many })` a new field kind. A term
2290
+ carries `id`, `parent`, `slug`, `position` and `labels` indexed by locale, and
2291
+ deliberately no `status`, `version` or `translationOf`: a classification is not
2292
+ content, so ADR-0014 does not govern it.
2293
+
2294
+ The tree is stored as a **materialised path** maintained on write, never a
2295
+ recursive CTE: "everything under this term" is one `like` that Postgres,
2296
+ MySQL/MariaDB and SQLite answer identically (ADR-0006). Paths are built from
2297
+ ids, so renaming a term rewrites nothing and only a move pays. Nesting is
2298
+ bounded at 12 levels so the indexed column stays inside InnoDB's key limit.
2299
+
2300
+ `createTaxonomyStore()` is the term store; `createSchemaTables(db, collections,
2301
+ taxonomies)` and `dropSchemaTables` take the taxonomies as a third argument.
2302
+
2303
+ ### The migration
2304
+
2305
+ `schema2Migration({ collections, taxonomies })` adds `deleted_at` to every
2306
+ entry table and creates the terms and join tables. It is marked **destructive**,
2307
+ so the migrator demands an explicit confirmation and a verified backup: its
2308
+ `down` drops `deleted_at` and the terms tables, which permanently discards
2309
+ everything in the trash and every classification — entries sitting in the trash
2310
+ silently become live again with no record they were ever deleted.
2311
+
2312
+ ### Also
2313
+
2314
+ `.cogenta/schema.json` reports `schema@2.0`, carries the declared taxonomies and
2315
+ each collection's trash window, and `buildSchemaDocument`/`renderSchemaJson`
2316
+ take the taxonomies. `@cogenta/core` gains the error codes the two features
2317
+ need: `CONTENT_REFERENCED`, `CONTENT_NOT_TRASHED` and the `TAXONOMY_*` family.
2318
+
2319
+ - [`ca71b3b`](https://github.com/cogenta-cms/cogenta/commit/ca71b3bbd5d5d7371923d0521444fc94a525de06) Thanks [@georgesmomo](https://github.com/georgesmomo)! - CORS, security headers and a coherent cache-control on `cogenta serve`
2320
+ (L10 task 6).
2321
+
2322
+ `@cogenta/core`'s configuration gains a `security` section:
2323
+
2324
+ ```ts
2325
+ security: {
2326
+ cors: { origins: ['https://app.example.com'], credentials: false },
2327
+ csp: "default-src 'self'",
2328
+ hstsMaxAge: 31536000,
2329
+ pageMaxAge: 60,
2330
+ }
2331
+ ```
2332
+
2333
+ Every field is off or permissive-by-omission by default, and that is a
2334
+ decision rather than timidity. CORS is off unless a site names an origin —
2335
+ the origin list *is* the switch, so "CORS is on" and "these origins may read
2336
+ it" cannot drift apart. HSTS is off unless asked and is never sent over plain
2337
+ HTTP: on a host that is not fully HTTPS it locks browsers out for `maxAge`
2338
+ seconds with no server-side undo, and it is the one header a wrong default can
2339
+ take a site offline with. Credentials together with the `*` origin is refused
2340
+ at startup, because every browser refuses that pair and a server that accepted
2341
+ it would look configured while granting nothing.
2342
+
2343
+ `cogenta serve` applies all of it in one place, before any route runs, so a
2344
+ route added later cannot opt out by forgetting:
2345
+
2346
+ - `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN` and
2347
+ `Referrer-Policy: strict-origin-when-cross-origin` on every response.
2348
+ - The configured CSP verbatim — a string, not a builder, because a CSP depends
2349
+ on which analytics, fonts and embeds a site actually uses.
2350
+ - CORS with an echoed (never blindly reflected) origin and `Vary: Origin`,
2351
+ plus a real preflight answer.
2352
+ - Cache-control by path class: `no-store` for `/api/*` and for the admin,
2353
+ `public, max-age=0, s-maxage=<pageMaxAge>, must-revalidate` for a public
2354
+ page, and the long immutable value image variants already set for
2355
+ themselves.
2356
+
2357
+ ### Patch Changes
2358
+
2359
+ - [`6ad0f3a`](https://github.com/cogenta-cms/cogenta/commit/6ad0f3a495176169fe95f4955dfef30a6af376fd) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Closes four denial-of-service and permission-escalation gaps a security review found in L19's document-upload pipeline and site-plan review screen, all reachable from a single uploaded file or a proposed content model — no LLM provider required to trigger them.
2360
+
2361
+ - `.docx` extraction (`packages/agents/src/documents/docx.ts`): the regex scanning `word/document.xml` for `<w:t>…</w:t>` runs backtracked quadratically on unclosed tags (measured 21.8 s for 400 KB). Replaced with a single linear `indexOf`-based scan, and `word/document.xml`/footnotes/endnotes are now capped at 8 MiB each (`zip.ts`'s `read()` gained a per-call `maxBytes`) instead of the shared 200 MiB decompression-bomb ceiling, since a highly repetitive XML payload can deflate at several hundred to one.
2362
+ - PDF stream collection (`packages/agents/src/documents/pdf.ts`): `collectStreams` used an unbounded `lastIndexOf` to find each stream's dictionary, which re-scans the entire prefix of the file for every stream found — a file that is mostly fake `stream`/`endstream` markers with no real PDF structure could cost minutes of CPU with no decompression involved. The search window is now bounded to 2 KiB behind each `stream` keyword, and the number of streams processed is capped at 10 000.
2363
+ - PDF text accumulation (`packages/agents/src/documents/pdf.ts`, `extract-text.ts`): `MAX_TEXT_CHARACTERS` was only enforced after every content stream had already been decoded and joined, so a PDF with many individually-small-enough, highly compressible streams could accumulate many times that budget in memory before truncation ever ran. The reader now stops pulling in further pages once the accumulated text already exceeds the cap, moved to a shared `limits.ts` so both `pdf.ts` and `extract-text.ts` read the same number.
2364
+ - Site plan review (`packages/agents/src/site-plan/content-model.ts`, `approval.ts`): a proposed content model's `permissions` is entirely the model's own choice, so a hallucinated or prompt-injected proposal granting `public` the `create`/`update`/`delete` actions would have let any anonymous visitor write to that collection once the plan was applied. `buildCollection` now refuses such a proposal outright (`CONTENT_MODEL_PROPOSAL_PERMISSIONS_UNSAFE`, fed back as the next attempt's correction like any other invalid proposal); separately, the human review screen (`summarisePlan`) now always shows a collection's proposed permissions and routing pattern, not only its fields and rationale, so a legitimate-but-surprising grant is visible before acceptance.
2365
+ - `cogenta serve` (`packages/cli/src/commands/serve.ts`): `readBody` had no byte limit, and the one route inviting multi-megabyte bodies by design (`/api/site-plans`) only checked the admin role after the body was fully buffered. `readBody` now caps every request body at 64 MiB, rejecting with a new `REQUEST_BODY_TOO_LARGE` error code (HTTP 413); `/api/site-plans` now checks the admin role before reading the body at all, so a non-admin caller — anonymous or not — is turned away before the server reads anything they sent.
2366
+
2367
+ ## 0.2.0
2368
+
2369
+ ### Minor Changes
2370
+
2371
+ - [`4c95475`](https://github.com/cogenta-cms/cogenta/commit/4c9547543ec9a4464d8c9a05d1967dd15b7953aa) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `THEME_IMAGE_UNSUPPORTED`, thrown by `cogenta serve`'s new theme-render
2372
+ fallback (`@cogenta/cli`) when a theme block asks for an image — no image
2373
+ pipeline is wired into that in-process fallback yet, so a theme gets a clear,
2374
+ typed refusal rather than a broken `<img>`.
2375
+
2376
+ ### Patch Changes
2377
+
2378
+ - [`fd0a52e`](https://github.com/cogenta-cms/cogenta/commit/fd0a52e155d802b102ac9012b3ed2d650b271c3f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `loadConfig` now auto-loads a `.env` file next to `cogenta.config.mjs`, using
2379
+ Node's own `process.loadEnvFile` (no new dependency, R9) — so a real secret
2380
+ like `COGENTA_AUTH_SIGNING_KEY` no longer has to be exported by hand in every
2381
+ shell before `cogenta serve` will start. Skipped whenever the resolved `env`
2382
+ is not really `process.env` (identity check, not `options.env === undefined`
2383
+ — real callers like the CLI's own `run()` resolve `options.env ?? process.env`
2384
+ once and thread that same object down explicitly, so `options.env` is
2385
+ "defined" even in a real, unconfigured shell; a test injecting its own
2386
+ synthetic map is still exempt, since that map is a different object).
2387
+
2388
+ Found via the user's own real end-to-end test: `npx cogenta serve` refused to
2389
+ start with "COGENTA_AUTH_SIGNING_KEY is not set", and the only documented fix
2390
+ was a manual, shell-specific `export`/`$env:` command with no Windows/Mac/
2391
+ Linux guidance. `create-cogenta` now writes a real generated key into `.env`
2392
+ (see `create-cogenta`'s own changeset) — this is the half of the fix that
2393
+ makes `cogenta serve` actually read it back.
2394
+
2395
+ ## 0.1.0
2396
+
2397
+ ### Minor Changes
2398
+
2399
+ - [`f323580`](https://github.com/cogenta-cms/cogenta/commit/f3235809422e16a4e9d34f16e1171d2ebcfaf01a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/cli` and its first command, `cogenta doctor`.
2400
+
2401
+ `doctor` reports which driver is running for each need, **why that one**, and what it
2402
+ costs. The "why" is the point: the registry can fall back from Redis to the filesystem
2403
+ without anyone noticing, and an operator who cannot see that has a site that is slower
2404
+ than they think for a reason nothing told them. Skipped drivers are listed with their
2405
+ reason too.
2406
+
2407
+ It also states out loud what would otherwise be discovered later — that a site with no
2408
+ LLM provider works apart from the agents, that SQLite is one machine with no vector
2409
+ index, and that signed media URLs will not survive a restart without
2410
+ `COGENTA_STORAGE_SIGNING_KEY`. An invalid configuration is reported as the offending
2411
+ fields rather than a stack trace, and exits non-zero so a deployment script notices.
2412
+
2413
+ Core gains `loadConfig` and `findConfigFile`, which walk up from the working directory
2414
+ the way a package manager looks for a lockfile. A missing config file is not an error: a
2415
+ container configured entirely through `COGENTA_*` and `DATABASE_URL` is a legitimate way
2416
+ to run.
2417
+
2418
+ - [`ea82de1`](https://github.com/cogenta-cms/cogenta/commit/ea82de10eba12d520e586b69e1bce733339da26d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add two error codes for L5's agent format and registry:
2419
+ `AGENT_DEFINITION_INVALID` (`defineAgent` given an empty name or
2420
+ identity document path) and `AGENT_UNKNOWN` (a registry operation named
2421
+ an agent that was never registered).
2422
+
2423
+ - [`8ae3456`](https://github.com/cogenta-cms/cogenta/commit/8ae3456d346ee2e169fceaa45c3cbaef1df01982) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add two error codes for L4's autonomy and approval layer:
2424
+ `TOOL_CALL_REJECTED` (a human rejected a tool call awaiting approval) and
2425
+ `APPROVAL_REQUEST_UNKNOWN` (deciding an approval request id that does not
2426
+ exist).
2427
+
2428
+ - [`0877503`](https://github.com/cogenta-cms/cogenta/commit/0877503bf4a999543d51ce6dda2126471a4852c0) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `create-cogenta` — the `blog` blueprint (L9 task 3): `post`/`category`/`tag`
2429
+ collections, real demo content seeded through `ContentStore`, the canonical
2430
+ theme's default skin (`theme.tokens.json`), and a recommended-agents hint
2431
+ (`.cogenta/recommended-agents.json`) — no live agent scheduler is wired,
2432
+ since none exists anywhere in this codebase yet (R2). `resolveBlueprint`
2433
+ now genuinely resolves `blog` as available; `blank`'s output is unchanged.
2434
+
2435
+ Also fixes a bare `throw new Error(...)` in `resolveBlueprint`'s internal
2436
+ consistency check, replaced with a `CogentaError`.
2437
+
2438
+ One new `@cogenta/core` error code: `BLUEPRINT_REGISTRY_CORRUPT`.
2439
+
2440
+ - [`dc674b2`](https://github.com/cogenta-cms/cogenta/commit/dc674b2dc8a375b8ace5881a3fb8601855888500) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the Postgres and MySQL/MariaDB database drivers.
2441
+
2442
+ Both run the same contract suite as SQLite, against real servers — the L0 exit criterion
2443
+ that the three databases pass one integration suite rather than three that resemble each
2444
+ other.
2445
+
2446
+ `postgres` (postgres.js) and `mysql2` are optional peer dependencies loaded through a
2447
+ dynamic import, so a site on SQLite installs neither and the default install still has no
2448
+ runtime dependency. postgres.js was chosen over `pg` because it has no transitive
2449
+ dependencies at all. Neither package appears in the published type declarations: each
2450
+ driver describes the slice of client API it uses structurally.
2451
+
2452
+ A transaction reserves a single connection for its whole duration. Issuing `BEGIN` on a
2453
+ pool would start the transaction on whichever connection happened to be free and run the
2454
+ following statements on others, silently outside it — a bug that only appears under
2455
+ concurrency, which is where it costs the most. Nested transactions become savepoints on
2456
+ both, matching SQLite.
2457
+
2458
+ `database.poolSize` is configurable and defaults to 5: shared hosting allows very few
2459
+ connections, and exhausting them takes a site down rather than slowing it. MySQL is
2460
+ opened with UTC and `dateStrings`, so a row does not read back differently depending on
2461
+ where the process runs.
2462
+
2463
+ - [`6f0b7bd`](https://github.com/cogenta-cms/cogenta/commit/6f0b7bdd457ba8d81e0aa18d0bde9b583bf810af) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/channels` (L6 task 1): the `ChannelAdapter` interface and
2464
+ `createChannelRegistry`, the foundation for the L6 lot ("Canaux" —
2465
+ Telegram, Slack, Discord, email, webhooks).
2466
+
2467
+ A message is described abstractly — `AlertChannelMessage`,
2468
+ `ReportChannelMessage`, `NotificationChannelMessage` — matching the lot's
2469
+ three fixed formats exactly, so no business code ever writes
2470
+ platform-specific Markdown. `ChannelIdentity.linkedUserId` is `string |
2471
+ null`, representing an unlinked channel identity as a first-class state:
2472
+ the lot's central security rule ("une commande entrante s'exécute avec les
2473
+ permissions de l'humain identifié, jamais avec celles de l'agent")
2474
+ requires that state to exist even before a later task enforces it.
2475
+ `InboundCommand` always carries the `ChannelIdentity` it came from, so a
2476
+ command cannot be routed without knowing who — if anyone — sent it.
2477
+
2478
+ `createChannelRegistry` mirrors `@cogenta/agents`'s `createProviderRegistry`:
2479
+ a site with zero channels configured works fine (R2's spirit), `get()` on
2480
+ an unconfigured name throws a typed `CogentaError` rather than returning
2481
+ `undefined`.
2482
+
2483
+ Two new `@cogenta/core` error codes: `CHANNEL_UNKNOWN`, `CHANNEL_DUPLICATE`.
2484
+
2485
+ - [`fd5ada9`](https://github.com/cogenta-cms/cogenta/commit/fd5ada927327946603a05349c2f87686ef8f003c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the email channel adapter (L6 task 8) — outbound-only (the lot's task
2486
+ list names this adapter without "commandes entrantes", unlike Telegram's
2487
+ task 4), buttonless: an `Alert`'s two actions render as HMAC-signed,
2488
+ single-use links (`## Approbations depuis le canal`'s explicit guidance for
2489
+ channels without buttons) reusing L6 task 5's real signing primitive rather
2490
+ than a new one.
2491
+
2492
+ R1-compliant `EmailTransport` interface with one real, tested,
2493
+ no-external-service implementation (`createFileEmailTransport`, writes each
2494
+ message to disk) — a real SMTP/HTTP-API transport is a deliberate,
2495
+ documented follow-up, not built in this pass; raw SMTP is a materially
2496
+ larger undertaking than Telegram's plain-HTTP Bot API and was judged
2497
+ disproportionate to this task's scope.
2498
+
2499
+ Two new `@cogenta/core` error codes: `CHANNEL_EMAIL_TRANSPORT_ERROR`,
2500
+ `CHANNEL_EMAIL_INBOUND_UNSUPPORTED`.
2501
+
2502
+ - [`22bb5b2`](https://github.com/cogenta-cms/cogenta/commit/22bb5b2903b35c79d80a7df0bb99bead1533ba55) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/channels`'s identity-linking mechanism (L6 task 2): a
2503
+ one-time code generated on the admin side, verified from a channel, tying
2504
+ a channel-side identity (`channelName`/`channelUserId`) to a real Cogenta
2505
+ user — the piece "## La règle de sécurité centrale" (`docs/lots/L6-canaux.md`)
2506
+ depends on.
2507
+
2508
+ `createChannelLinkStore(db, now?)` — `generateCode`, `verifyCode`,
2509
+ `resolveIdentity`, `revoke`, `listLinkedChannels`, all real, persisted
2510
+ (SQLite/Postgres/MySQL via `ensureChannelTables`, following
2511
+ `@cogenta/auth`'s `ensureAuthTables` pattern — no separate migration file).
2512
+
2513
+ Codes are 8 characters from a 32-symbol unambiguous alphabet (Crockford-style,
2514
+ `0`/`O`/`1`/`I`/`L` removed), 40 bits of entropy, single-use, a short
2515
+ default TTL (10 minutes, "valable quelques minutes" per the lot doc) —
2516
+ judged against brute-forcing one code within its TTL window, not against
2517
+ long-term-secret standards (session tokens remain 256 bits). Stored hashed,
2518
+ never plain, like a session token.
2519
+
2520
+ `verifyCode` rejects every failure kind — nonexistent, expired,
2521
+ already-used, wrong channel — with the same uniform `CHANNEL_LINK_CODE_INVALID`
2522
+ error, so a caller cannot accidentally build a channel-facing reply that
2523
+ leaks which reason applied (an enumeration oracle against unlinked
2524
+ identities).
2525
+
2526
+ One new `@cogenta/core` error code: `CHANNEL_LINK_CODE_INVALID`.
2527
+
2528
+ - [`50d3b40`](https://github.com/cogenta-cms/cogenta/commit/50d3b4041fb5392502711c2bf20f4ec92d2ce76d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/channels`'s inbound command routing (L6 task 3) — the
2529
+ payoff for tasks 1 and 2: "## La règle de sécurité centrale"
2530
+ (`docs/lots/L6-canaux.md`), **"Une commande entrante s'exécute avec les
2531
+ permissions de l'humain identifié, jamais avec celles de l'agent."**
2532
+
2533
+ `authorizeInboundCommand(identity, requiredRoles, getUserRoles)` — the
2534
+ single security gate every inbound command passes through. An unlinked
2535
+ identity (`linkedUserId: null`) is refused with `shouldReply: false`,
2536
+ matching "Une identité de canal non liée à un compte est ignorée, sans
2537
+ réponse — répondre confirmerait l'existence du bot à un inconnu": a
2538
+ consuming adapter that just checks the flag gets that property for free.
2539
+ A linked-but-unauthorized user is refused with `shouldReply: true`
2540
+ (they're a known person and may be told so). An authorized result always
2541
+ carries the identity's real, verified `linkedUserId` — never anything
2542
+ read off the inbound payload itself.
2543
+
2544
+ `createCommandRouter({getUserRoles})` — parses `/name args`, looks up a
2545
+ registered handler, and routes through `authorizeInboundCommand` before
2546
+ ever invoking it. The unlinked check happens before even checking whether
2547
+ the command is recognized, so an unlinked stranger gets silence for
2548
+ *any* input, not just for commands that exist — an "unknown command"
2549
+ reply would itself leak the bot's existence.
2550
+
2551
+ `requiredRoles` reuses contract A's own open role-name-array convention
2552
+ (`CollectionDefinition.permissions`, `@cogenta/api`'s `PermissionLayer`)
2553
+ rather than a parallel permission-string system.
2554
+
2555
+ One new `@cogenta/core` error code: `CHANNEL_COMMAND_DUPLICATE`.
2556
+
2557
+ - [`27e32b5`](https://github.com/cogenta-cms/cogenta/commit/27e32b52ed11e97969e2b319b2e74345bbc1f213) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add real constructors for the lot's three fixed message levels (L6 task 6)
2558
+ — `buildAlert`/`buildReport`/`buildNotification` (`src/formats/`) — that
2559
+ validate the exact rules `## Formats de message` states in prose: an alert
2560
+ requires a title/context/expected-action and a real admin URL; a report
2561
+ requires at least one key figure and refuses to exceed an abstract
2562
+ 480-character screen budget unless a `moreUrl` fallback is given; a
2563
+ notification must be a real, non-empty single line. `approvals/message.ts`
2564
+ now builds its alert through `buildAlert` instead of a hand-assembled
2565
+ literal, so it gets the same validation for free.
2566
+
2567
+ Also hardens the Telegram adapter's report rendering with a real,
2568
+ last-resort truncation at Telegram's actual 4096-character `sendMessage`
2569
+ limit — the `moreUrl` footer is never the part that gets cut, since it's
2570
+ the reader's only way to the full detail.
2571
+
2572
+ New `@cogenta/core` error code: `CHANNEL_MESSAGE_INVALID`.
2573
+
2574
+ - [`962073f`](https://github.com/cogenta-cms/cogenta/commit/962073f3aa5e56e68869c7d14a4b2937e506cfbd) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/channels`'s notification preferences and grouping (L6 task
2575
+ 7) — per `(userId, channelName)`: opted-in event types, minimum severity,
2576
+ quiet hours, and a grouping mode (`immediate | hourly | daily`).
2577
+
2578
+ A `NotificationDispatcher.notify()` filters against these preferences and
2579
+ either sends immediately or queues the notification; `flushDue()`
2580
+ collapses every due group into a single message (a `Report` via
2581
+ `buildReport` for more than one queued item, a `Notification` for
2582
+ exactly one) — this is what turns fifteen dependency-scan findings into
2583
+ one grouped message instead of fifteen separate ones
2584
+ ("## Préférences", `docs/lots/L6-canaux.md`).
2585
+
2586
+ Quiet hours defer a non-critical notification until the window ends
2587
+ rather than dropping it; a `critical`-severity notification always
2588
+ bypasses quiet hours. Preferences persist via a new `cogenta_channel_preferences`
2589
+ table (`ensurePreferenceTables`), following the same `create table if
2590
+ not exists` pattern as `ensureChannelTables`.
2591
+
2592
+ One new `@cogenta/core` error code: `CHANNEL_PREFERENCES_INVALID`.
2593
+
2594
+ - [`59aced9`](https://github.com/cogenta-cms/cogenta/commit/59aced90e97d3aa2a98ab5e7aa067f50e2ceb611) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the Slack channel adapter (L6 task 9) — the second real
2595
+ `ChannelAdapter`, wired to the same identity linking and inbound command
2596
+ routing Telegram (task 4) already proved.
2597
+
2598
+ Zero-dependency: a small hand-typed client (`createSlackClient`) calls
2599
+ Slack's plain HTTPS/JSON Web API directly via `fetch`, same reasoning as
2600
+ the Telegram client. Socket Mode, not the Events API webhook — Slack's own
2601
+ real, officially-supported answer to "no public HTTPS endpoint," same
2602
+ reasoning as Telegram's long-polling choice (no plane of this project is
2603
+ deployed publicly yet). `createSlackSocketClient` opens a WebSocket via
2604
+ `apps.connections.open`, using Node's built-in `WebSocket` global (stable
2605
+ since this project's Node 22 minimum) — no new dependency needed for the
2606
+ transport either. A `disconnect` envelope triggers exactly one
2607
+ reconnect, mirroring Telegram's continuous poll loop.
2608
+
2609
+ Rate limiting: Slack signals a 429 via an HTTP `Retry-After` header
2610
+ (unlike Telegram's JSON `retry_after` field) — read correctly and
2611
+ retried with the real value, never a guessed backoff.
2612
+
2613
+ Message rendering (`renderSlackMessage`) turns the abstract
2614
+ `ChannelMessage` into Slack Block Kit blocks, entirely inside this
2615
+ adapter. A button's `action_id`/`value` is literally the command text it
2616
+ routes as — a Block Kit button press (`block_actions`) goes through the
2617
+ exact same `CommandRouter.route()` a typed message does, never a second,
2618
+ parallel authorization path. An unlinked identity's message is tried
2619
+ once as a linking code, exactly like Telegram; every other case stays
2620
+ silent.
2621
+
2622
+ Capabilities declared honestly: `threads`/`attachments` are `false` —
2623
+ not built this pass, deferred rather than half-implemented.
2624
+
2625
+ One new `@cogenta/core` error code: `CHANNEL_SLACK_API_ERROR`.
2626
+
2627
+ - [`b26dd9f`](https://github.com/cogenta-cms/cogenta/commit/b26dd9f636095b126ceb78e69bda50f7f5f8cb52) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the Telegram channel adapter (L6 task 4) — "Telegram en premier,
2628
+ complet": the first live `ChannelAdapter`, wired to tasks 2/3's identity
2629
+ linking and inbound command routing.
2630
+
2631
+ Zero-dependency: a small hand-typed client (`createTelegramClient`) calls
2632
+ Telegram's plain HTTPS/JSON Bot API directly via `fetch`, following this
2633
+ project's established precedent (`@cogenta/import`'s WXR parser,
2634
+ `@cogenta/mcp`'s JSON-RPC subset) of a small hand-rolled client over a new
2635
+ SDK dependency for a REST API this simple.
2636
+
2637
+ Transport: long-polling (`getUpdates`), not a webhook — a webhook needs a
2638
+ real public HTTPS endpoint and Telegram's own signature verification, and
2639
+ no plane of this project is deployed publicly yet (L9 task 12's scoping).
2640
+ Polling works unchanged wherever `cogenta serve` already runs.
2641
+
2642
+ Rate limiting: a 429 response is retried using Telegram's own
2643
+ `retry_after` value, never a guessed backoff — "Prévoir la file, le
2644
+ backoff et le regroupement dès le premier adaptateur."
2645
+
2646
+ Message rendering (`renderTelegramMessage`) turns the abstract
2647
+ `ChannelMessage` (alert/report/notification) into MarkdownV2 text plus
2648
+ inline keyboard buttons, entirely inside this adapter — "on n'écrit pas
2649
+ de Markdown Telegram dans le code métier." A button's `callback_data` is
2650
+ literally the command text it routes as: a button press goes through the
2651
+ exact same `CommandRouter.route()` a typed command does, never a second,
2652
+ parallel authorization path. An unlinked identity's message is tried
2653
+ once as a linking code (confirmed on success, silent on any failure) —
2654
+ "Une identité de canal non liée à un compte est ignorée, sans réponse"
2655
+ still holds for everything else.
2656
+
2657
+ One new `@cogenta/core` error code: `CHANNEL_TELEGRAM_API_ERROR`.
2658
+
2659
+ - [`f52f97f`](https://github.com/cogenta-cms/cogenta/commit/f52f97ff8c553ab44f715b55f37ac726ea335160) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the generic signed webhook channel (L6 task 11, the final task of the
2660
+ L6 lot) — the security primitive "## Pièges connus" names explicitly:
2661
+ "Les webhooks entrants sont une surface d'attaque. Vérification de
2662
+ signature obligatoire, fenêtre temporelle, protection contre le rejeu."
2663
+
2664
+ `verifyIncomingWebhook` checks all three, mandatory: HMAC-SHA256
2665
+ signature authenticity (constant-time comparison, same construction as
2666
+ `approvals/signed-link.ts`), timestamp freshness against an injectable
2667
+ clock (default 5-minute window), and replay rejection via a bounded
2668
+ in-memory `WebhookReplayGuard` — a request failing any check is rejected
2669
+ with a distinguishable, typed `CogentaError` (unlike identity-linking's
2670
+ deliberately uniform code, there is no enumeration oracle here: a
2671
+ webhook secret is either configured correctly or it isn't, and
2672
+ distinguishing "bad signature" from "stale timestamp" from "already
2673
+ processed" is a legitimate operator need). `signOutgoingWebhook` is the
2674
+ matching outbound half `createWebhookAdapter`'s `send()` uses on every
2675
+ request — round-trip tested against the real verifier, not just each
2676
+ half in isolation.
2677
+
2678
+ `createWebhookAdapter` is outbound-only: `capabilities.buttons` is
2679
+ `false` (no UI to click — actions render as real signed links, reusing
2680
+ the same primitive the email adapter, task 8, already consumes) and
2681
+ `capabilities.inbound` is `false` — real inbound command execution for
2682
+ an arbitrary third-party caller is a materially larger undertaking
2683
+ (a live HTTP route, per-integration identity decisions) than this
2684
+ task's actual deliverable, the signing/verification primitive itself,
2685
+ which is complete and exercised end-to-end. `verifyIdentity` is an
2686
+ honest refusal, matching the email adapter's precedent.
2687
+
2688
+ Three new `@cogenta/core` error codes: `CHANNEL_WEBHOOK_SIGNATURE_INVALID`,
2689
+ `CHANNEL_WEBHOOK_EXPIRED`, `CHANNEL_WEBHOOK_REPLAY_DETECTED`, plus
2690
+ `CHANNEL_WEBHOOK_DELIVERY_FAILED` and `CHANNEL_WEBHOOK_INBOUND_UNSUPPORTED`.
2691
+
2692
+ L6 ("Canaux") is now complete — all 11 tasks done.
2693
+
2694
+ - [`39b6d33`](https://github.com/cogenta-cms/cogenta/commit/39b6d339a52ba97a1437167c15910971eee02383) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the database layer: the dialect abstraction, the SQLite driver and their contract
2695
+ suite.
2696
+
2697
+ Callers write `` sql`… ${value}` `` and never a placeholder: Postgres wants `$1` where
2698
+ MySQL and SQLite want `?`, and letting that reach a call site is the dialect leak the
2699
+ design warns about. The same layer quotes identifiers per dialect and adapts values —
2700
+ SQLite has no boolean and no date type, MySQL's `datetime` carries no time zone — so a
2701
+ caller never has to know which database is connected. Interpolated values are always
2702
+ bound; only `unsafeRaw`, named to say so, inserts text verbatim.
2703
+
2704
+ The SQLite driver uses Node's built-in `node:sqlite`, so the default install compiles
2705
+ nothing and depends on nothing. `better-sqlite3` is deliberately avoided: it is native
2706
+ code, and rule R10 forbids that without a fallback because it breaks on ARM, musl and
2707
+ shared hosting — the deployments SQLite exists to serve. WAL mode, a busy timeout and
2708
+ foreign keys are on from the first connection, and nested transactions map onto
2709
+ savepoints so two functions that each want a transaction compose.
2710
+
2711
+ - [`6322731`](https://github.com/cogenta-cms/cogenta/commit/632273109648e850e415bb179bea6e5ea027c500) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the `HTTP_FETCH_DOMAIN_DENIED` error code, thrown by `@cogenta/agents`'
2712
+ `http.fetch` core tool when a requested URL's host is not on the calling
2713
+ agent's allowed domain list.
2714
+
2715
+ - [`4921407`](https://github.com/cogenta-cms/cogenta/commit/4921407b4dbd283bdd76cf74d288a79c2ebcab64) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/auth` — passwords, TOTP, WebAuthn passkeys, opaque sessions, progressive
2716
+ login rate-limiting, and a hash-chained audit log, tested against a real SQLite database
2717
+ (no mocked database, per AGENTS.md).
2718
+
2719
+ Passwords use `scrypt` from `node:crypto` at the OWASP floor (N=2^15), never bcrypt or
2720
+ argon2 — both are native modules R10 forbids without a WASM fallback, and neither ships
2721
+ one. TOTP (RFC 6238) is hand-written, forty lines of unambiguous HMAC; WebAuthn is a
2722
+ justified dependency (`@simplewebauthn/server`, MIT, pure JS) because attestation
2723
+ verification is a large, security-relevant surface no homegrown subset should touch.
2724
+
2725
+ MFA is mandatory, not configurable, for the `admin` role and for any role a collection
2726
+ grants `publish` to — computed from `CollectionDefinition[]`, so it tracks the schema
2727
+ rather than a setting someone can switch off under deadline pressure. A short-lived
2728
+ HMAC-signed ticket (the same shape as a preview grant) carries a verified password step
2729
+ into the second-factor step without server-side state.
2730
+
2731
+ Sessions are opaque random bearer tokens, stored hashed like a password, sliding TTL —
2732
+ never a JWT, so "sign out of every device" is a real revoke rather than a wait for
2733
+ expiry. The audit log is append-only and hash-chained; `verify()` detects a row edited or
2734
+ deleted outside of `record()`, and this table is built to take a second writer once L4's
2735
+ agents need to log to the same place.
2736
+
2737
+ `newId`/`isUuidV7`/`timestampOf` move from `@cogenta/schema` to `@cogenta/core`, since
2738
+ `@cogenta/auth` now needs them too; `@cogenta/schema` re-exports them unchanged.
2739
+
2740
+ - [`7d9ed38`](https://github.com/cogenta-cms/cogenta/commit/7d9ed3878de61d54e58a4aa027c72447c118761c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the `database` job queue driver — the degraded tier that needs no Redis and no
2741
+ persistent worker.
2742
+
2743
+ Jobs are claimed with `FOR UPDATE SKIP LOCKED` on Postgres, MySQL and MariaDB, and under
2744
+ the write lock on SQLite. Two different mechanisms, one guarantee, proven by one contract
2745
+ suite: L0's acceptance criterion is that two concurrent workers never process the same
2746
+ job, and it is asserted with real connections racing on a real database rather than a
2747
+ mock. A claim that loses an InnoDB deadlock retries, because both MySQL and Postgres
2748
+ document that as the remedy rather than a failure.
2749
+
2750
+ A worker only claims jobs it has a handler for, so two workers with different handlers
2751
+ take their own work instead of locking jobs they would have to put back. A job whose
2752
+ worker dies is released when its lease expires. Failures retry with exponential backoff
2753
+ and stop at `maxAttempts`, recording why.
2754
+
2755
+ Two dialect traps are now handled in the db layer rather than by callers: `LIMIT` renders
2756
+ as a literal, because MySQL prepared statements reject a placeholder there, and the SQLite
2757
+ driver serialises statements per file within a process — `node:sqlite` is synchronous, so
2758
+ a second connection issuing a write while the first holds a transaction deadlocks the
2759
+ event loop rather than waiting.
2760
+
2761
+ - [`046ffa8`](https://github.com/cogenta-cms/cogenta/commit/046ffa85769066150a0d0e8443d0d257ef72239c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add one error code for L5's evaluation harness: `EVAL_THRESHOLD_NOT_MET`
2762
+ (`assertEvalThreshold`'s suite mean score fell below the required
2763
+ minimum — the mechanism a `*.eval.test.ts` file uses to fail CI on a
2764
+ prompt or model regression).
2765
+
2766
+ - [`a958ee1`](https://github.com/cogenta-cms/cogenta/commit/a958ee12cee1130effb97e95d58fda219e153a4c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/api`: the permission layer, preview tokens, REST and GraphQL.
2767
+
2768
+ Both transports run on one permission layer, as the lot requires. The hardest rule —
2769
+ the `public` role never reaches a draft, on any route, in either transport, whatever the
2770
+ query says — is enforced structurally rather than by condition: `canReadUnpublished`
2771
+ strips `public` from the actor's roles before looking at anything, so even a collection
2772
+ misconfigured with `update: ['public']` cannot become draft access.
2773
+
2774
+ A preview token is the single deliberate exception, and it is scoped to one entry. That
2775
+ scoping is not free: `canReadUnpublished` is only told which collection is being read, so
2776
+ a grant for entry A would otherwise unlock every draft in it. Every path that returns
2777
+ entries filters each one through `previewCovers` — the list, the paginated connection,
2778
+ reads by id, and relation expansion including the batching loader.
2779
+
2780
+ REST is a router over normalised request and response objects, with no HTTP framework and
2781
+ no listening socket, so it is tested without a server. Filters use a fixed vocabulary and
2782
+ values are coerced from the declared field kind, because a text comparison would rank
2783
+ `"10"` below `"9"`. GraphQL derives its schema from the collections, prints the same
2784
+ object it executes, and batches relation reads through a thirty-line dataloader written
2785
+ here rather than taken as a dependency.
2786
+
2787
+ - [`39fc7a4`](https://github.com/cogenta-cms/cogenta/commit/39fc7a4d490f0a1683ef69dd5495e0ff6494ca72) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add typed errors and configuration loading.
2788
+
2789
+ `CogentaError` carries a stable `code`, an actionable `hint` and opt-in structured
2790
+ `details`. It is the only error library code throws — a bare `throw new Error("…")`
2791
+ gives callers nothing to branch on and users nothing to act on.
2792
+
2793
+ `defineConfig` types `cogenta.config.ts`; `resolveConfig` validates it and applies
2794
+ defaults, then environment overrides. Secrets (`llm.apiKey`, `storage.accessKeyId`,
2795
+ `storage.secretAccessKey`) are rejected in the config file and read from the
2796
+ environment only. Unknown keys are errors rather than silently ignored settings, an
2797
+ invalid configuration reports every offending field at once, and the database driver
2798
+ is inferred from the URL scheme when it is not named.
2799
+
2800
+ - [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the skin system of contract D to `@cogenta/render`: design tokens, CSS variables and
2801
+ hot swap.
2802
+
2803
+ **Tokens.** `validateSkin` takes a raw `tokens.json` and returns it typed, or refuses it.
2804
+ The token set is closed: a missing token *and* an unknown token are both refused, so a
2805
+ skin can never leave a variable undefined nor smuggle in presentation the theme never
2806
+ declared.
2807
+
2808
+ **Validation, in hard-refusal mode.** WCAG 2.2 AA contrast on the three declared pairs
2809
+ (`fg`/`bg`, `accentFg`/`accent`, `mutedFg`/`muted`), a strictly increasing typographic
2810
+ ladder, well-formed colours, lengths and durations, and `motion.reduced`. A refusal names
2811
+ every failure of its category at once — for contrast, the pair, its measured ratio and the
2812
+ shortfall. Relative luminance and the contrast ratio are computed in the package, without
2813
+ a dependency.
2814
+
2815
+ **CSS.** `renderSkinCss` emits one stylesheet of `--cogenta-<group>-<name>` custom
2816
+ properties, plus the derived font-size ladder and a density multiplier, and honours
2817
+ `prefers-reduced-motion` in the sheet itself rather than only in the token.
2818
+
2819
+ **Hot swap.** `createSkinStore(tokens).apply(next)` validates and rewrites the sheet with
2820
+ no build step, in well under a millisecond, and keeps the previous skin live if the new
2821
+ one is refused. Each sheet carries a content ETag that is stable for identical tokens.
2822
+
2823
+ New error codes in `@cogenta/core`: `SKIN_TOKEN_MISSING`, `SKIN_TOKEN_UNKNOWN`,
2824
+ `SKIN_TOKEN_INVALID`, `SKIN_CONTRAST_INSUFFICIENT`, `SKIN_SCALE_NOT_MONOTONIC`,
2825
+ `SKIN_MOTION_NOT_REDUCED`.
2826
+
2827
+ - [`2a044a1`](https://github.com/cogenta-cms/cogenta/commit/2a044a1689f98a25258b6f45d9baf0b325194c95) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the `bullmq` job queue driver — the optimal tier, on Redis — as the counterpart to the
2828
+ `database` driver. Both now run **one** contract suite, so a site that loses its Redis
2829
+ falls back without a line of calling code changing.
2830
+
2831
+ `bullmq` stays an optional peer: it is loaded by dynamic import through `loadBullmqModule()`,
2832
+ which returns `null` when it is absent, and the API it exposes is described structurally so
2833
+ the published type declarations never reference it. A site on the database queue installs
2834
+ neither `bullmq` nor `ioredis`, and still typechecks.
2835
+
2836
+ Jobs are fetched by hand rather than by a `Worker` loop, because `tick()` is the call both
2837
+ drivers answer to — cron drives it on shared hosting, and it has to mean the same thing on
2838
+ Redis. The atomic claim stays inside Redis, so the L0 acceptance criterion holds: four
2839
+ workers draining twenty-four jobs never process one twice. A job whose worker was killed is
2840
+ returned to the queue by bullmq's stalled checker, which manual fetching does not start on
2841
+ its own — the driver starts it.
2842
+
2843
+ Two mappings are worth knowing. Cogenta priorities run high-first and bullmq's run low-first
2844
+ with `0` reserved, so priorities are mirrored onto a mid-range origin; and bullmq has no
2845
+ cancelled state, so a cancelled job is removed and recorded in a tombstone hash under the
2846
+ driver's own key prefix. Job ids carry the job name, because bullmq shards by queue.
2847
+
2848
+ `available()` opens a connection and pings rather than trusting that a URL is configured, and
2849
+ `health()` never reports the URL — it routinely carries a password.
2850
+
2851
+ - [`5ae4e24`](https://github.com/cogenta-cms/cogenta/commit/5ae4e24e59cf807ef7aca5839623fd8a24798435) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the MCP client: `createMcpStdioClient` spawns a third-party MCP
2852
+ server as a child process and speaks the same stdio JSON-RPC protocol
2853
+ as the server side (task 17). `wrapMcpTool` turns a remote tool into an
2854
+ ordinary `ToolDefinition` — permissions, `sideEffects`, `reversible` and
2855
+ `cost` are declared by the integrator, never trusted from the remote
2856
+ server, so a wrapped remote tool passes through the exact same registry,
2857
+ manifest, audit and autonomy pipeline as an internal one.
2858
+
2859
+ Two new `@cogenta/core` error codes: `MCP_CLIENT_REMOTE_ERROR` (the
2860
+ remote server answered with a JSON-RPC protocol error) and
2861
+ `MCP_CLIENT_TOOL_FAILED` (the remote tool itself reported `isError:
2862
+ true`).
2863
+
2864
+ - [`77ff957`](https://github.com/cogenta-cms/cogenta/commit/77ff95771e3fc415d9581e8d51ccae200167703d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add one error code for L4's memory layer:
2865
+ `AGENT_APPROVAL_NOT_DECIDED` (converting a still-pending approval request
2866
+ into a memory record — only a decided one carries a learning signal).
2867
+
2868
+ - [`3021aa1`](https://github.com/cogenta-cms/cogenta/commit/3021aa1c65d708b1267c662ce925d560f735d7d0) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the structured logger.
2869
+
2870
+ `createLogger` emits one JSON object per line — never free text — with a level, an ISO
2871
+ timestamp, a message and the caller's fields merged at the top level. Fields cannot
2872
+ overwrite the record structure. `child()` binds context that repeats on every record
2873
+ without touching its parent.
2874
+
2875
+ Every record passes through redaction on the way out: by field name (`apiKey`,
2876
+ `secretAccessKey`, `authorization`), by value shape (provider key prefixes, private key
2877
+ blocks, JWTs) and inside connection strings, where only the password is replaced so the
2878
+ URL stays readable for debugging. Fields that merely look related — `tokens`,
2879
+ `tokensPerDay`, `cacheKey` — are left alone, because over-redacting makes logs useless.
2880
+
2881
+ `Error` values are unpacked explicitly rather than left to `JSON.stringify`, which
2882
+ renders them as `{}`, and an unserialisable field drops the field rather than throwing
2883
+ in the caller's face.
2884
+
2885
+ - [`b2ecf93`](https://github.com/cogenta-cms/cogenta/commit/b2ecf9310366fcbaf18fbbf2c71bc45fccc577da) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Wire Drizzle onto the database layer, on all three dialects.
2886
+
2887
+ Every dialect goes through Drizzle's **proxy** driver rather than the driver Drizzle
2888
+ ships for it, so ORM traffic runs on the same connection as raw SQL — one pool, the same
2889
+ transaction pinning, the same typed errors, and the same rule that a parameter value
2890
+ never reaches an error message. On SQLite there was no choice anyway: `better-sqlite3` is
2891
+ forbidden by rule R10 and `node:sqlite` has no Drizzle driver.
2892
+
2893
+ `SqlExecutor` grows three things the bridge needs and nothing else has to use: `dialect`
2894
+ moves down from `DatabaseHandle` so a transaction executor knows what it is talking to,
2895
+ `execute()` runs SQL that is already rendered for the dialect without encoding its values
2896
+ a second time, and `asArrays` returns rows as ordered values — a join selecting
2897
+ `users.id` and `posts.id` loses one of them in an object keyed by column name.
2898
+
2899
+ `db.transaction()` on a proxy instance is not usable. Use `drizzleTransaction`, which
2900
+ runs the work inside a handle transaction and rebuilds the instance on its executor, so
2901
+ every statement lands on the pinned connection and rolls back with it.
2902
+
2903
+ - [`8d3b27c`](https://github.com/cogenta-cms/cogenta/commit/8d3b27ce2334c7ea6e75182707aa6d6e78688b31) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the three build targets — static, Node SSR, edge — and the refusal that keeps a static
2904
+ build honest.
2905
+
2906
+ The target is a build parameter, never a theme variant: the renderer is handed the route
2907
+ and nothing else, so it *cannot* branch on the target. That is what makes "the same
2908
+ content produces an equivalent result on the three targets" a property of the code rather
2909
+ than a promise, and the equivalence test compares the three builds byte for byte.
2910
+
2911
+ A build collects every declared runtime need first — blocks, theme, plugins — and judges
2912
+ afterwards. `collectionList` is the one block of the twelve that declares
2913
+ `runtime: 'server'`, so the case is real on the first site that places a listing. On a
2914
+ static target the build is refused before a single page is rendered, with a message that
2915
+ names the block, the pages it sits on and their block keys, why a static build cannot
2916
+ carry it, and the three ways out: build for `--target node` or `--target edge`, move the
2917
+ work to an external service the browser calls, or remove the element. Every offending
2918
+ element is reported at once, so an operator takes one decision rather than one build per
2919
+ problem. Nothing degrades silently: no dropped block, no build-time snapshot of a live
2920
+ list.
2921
+
2922
+ On the two request-time targets the split is hybrid and identical: pages with a
2923
+ server-side need are served on request, the rest are still prerendered. An unregistered
2924
+ block is refused rather than assumed static — guessing a runtime is how a server-side
2925
+ block slips into a static build.
2926
+
2927
+ Core adds two error codes, `BUILD_TARGET_UNKNOWN` and `BUILD_RUNTIME_UNSATISFIED`.
2928
+
2929
+ - [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the core of `@cogenta/schema`: `defineCollection`, the fourteen field types of
2930
+ contract A, the system fields, and the two generated artefacts.
2931
+
2932
+ `f.text()`, `f.richText()`, `f.slug()`, `f.number()`, `f.boolean()`, `f.date()`,
2933
+ `f.datetime()`, `f.media()`, `f.relation()`, `f.select()`, `f.json()`, `f.geo()`,
2934
+ `f.color()` and `f.blocks()` each produce a plain, serialisable field definition and a
2935
+ Zod validator derived from it — one validator, generated from the schema, never a second
2936
+ one written by hand next to it.
2937
+
2938
+ `defineCollection` checks a definition at import time and reports **every** problem at
2939
+ once, each located by the field it concerns (`fields.author.onDelete`,
2940
+ `indexes[0]`, `routing.pattern`), rather than one per run. A default value the field
2941
+ itself would reject, a slug derived from a field nobody declared, `'setNull'` on a
2942
+ required relation, an action outside the five of the contract: all refused before a
2943
+ migration exists.
2944
+
2945
+ `renderTypeDeclarations()` produces `.cogenta/types.d.ts` — one interface per collection,
2946
+ extending the system fields, importing nothing so a theme compiles against it without
2947
+ depending on the schema package. A theme reading a field that no longer exists now fails
2948
+ to build, which is the acceptance criterion of L1. `renderSchemaJson()` produces
2949
+ `.cogenta/schema.json`, the description the admin reads. Both are pure functions
2950
+ returning strings; the CLI writes the files.
2951
+
2952
+ `richText` stores the restricted Portable Text document of ADR-0013 — no HTML, no `h1`,
2953
+ internal links referencing an entity rather than a URL — and rejects a mark that no
2954
+ `markDefs` entry defines or two nodes sharing a `_key`. Ids are application-minted
2955
+ UUIDv7 (ADR-0015), monotonic inside a millisecond so they stay ordered.
2956
+
2957
+ Core gains the `SCHEMA_INVALID` error code.
2958
+
2959
+ - [`8d3b27c`](https://github.com/cogenta-cms/cogenta/commit/8d3b27ce2334c7ea6e75182707aa6d6e78688b31) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the image pipeline, the three build targets, the tag-invalidated page cache and the
2960
+ PWA to `@cogenta/render`.
2961
+
2962
+ Images are a driver like any other: `sharp` at the optimal tier as an optional peer, a
2963
+ WebAssembly libvips fallback at the degraded tier, and one contract suite run against
2964
+ both. The fallback runs **unconditionally**, not when `sharp` happens to be missing — a
2965
+ suite that stops exercising it on the maintainer's laptop is exactly the hole L3 warns
2966
+ about. `/_image` caps requested dimensions, because it is a public URL and a loop over
2967
+ widths would otherwise be a cache-filling attack.
2968
+
2969
+ A build target is a parameter, never a theme variant: the renderer is handed a route and
2970
+ returns a string, so a theme cannot branch on the target even if it wanted to, and
2971
+ equivalence across static, Node and edge is structural rather than promised. A static
2972
+ build carrying a `runtime: 'server'` block is refused with a message naming the element,
2973
+ where it sits in the site, and three numbered ways out — asserted byte for byte so the
2974
+ wording cannot quietly degrade.
2975
+
2976
+ The page cache derives its tags by instrumenting what a render actually read, not by
2977
+ declaration, which would be wrong at the first omission. A list page carries its
2978
+ collection's tag and a detail page does not, so publishing an entry that was never in
2979
+ the cached page still drops the list.
2980
+
2981
+ - [`40539fc`](https://github.com/cogenta-cms/cogenta/commit/40539fcd48da958dba69f9a32f0b440f868d539f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the Performance agent: `queryCrux` measures Core Web Vitals via the
2982
+ Chrome UX Report API (real-user field data on the deployed site, no
2983
+ headless browser); `medianMetrics` combines several noisy samples
2984
+ before `compareToBudget` or `detectRegression` ever run
2985
+ (`detectRegression`'s default 15% threshold is deliberately generous,
2986
+ so normal field-data jitter never gets reported as a regression);
2987
+ `diagnosePerformanceRisks` flags only structurally-derivable causes
2988
+ (missing image dimensions, unoptimized images, too many third-party
2989
+ scripts) — it does not guess at causes it cannot back with data.
2990
+ `performanceAgent` ties it together with the lot's tool list
2991
+ (`http.fetch`/`content.read`/`channel.send`/`build.trigger` — no
2992
+ content-writing tools).
2993
+
2994
+ One new `@cogenta/core` error code: `PERFORMANCE_CRUX_QUERY_FAILED`.
2995
+
2996
+ - [`24b1745`](https://github.com/cogenta-cms/cogenta/commit/24b174536c79a7b0f505e1ba4e70d5070fb14f6d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - L9 task 12 ("Site du projet et playground"), the buildable slice the lot itself calls out: "commencer par une démo en lecture seule réinitialisée périodiquement."
2997
+
2998
+ - `@cogenta/schema`: new `withReadOnlyStore(store)` — wraps any `ContentStore` so `create`/`update`/`delete`/`publish`/`unpublish`/`restore` refuse with a real `CONTENT_READ_ONLY` error while every read passes through unchanged.
2999
+ - `@cogenta/cli`: `runServe`'s `ServeOptions` gained a `readOnly` flag. Wrapped once, at the single point `serve.ts` constructs every `ContentStore` — both REST's `ContentService` and GraphQL's gateway share it, so neither transport can bypass the guard.
3000
+ - `@cogenta/api`: `CONTENT_READ_ONLY` maps to HTTP 403.
3001
+ - `@cogenta/core`: two new error codes — `CONTENT_READ_ONLY`, `PLAYGROUND_BLUEPRINT_UNKNOWN`.
3002
+ - `create-cogenta`: new `resetPlaygroundData()` — wipes and reseeds a blueprint's tables back to its own real demo content (`BLUEPRINT_CONTENT_PACKS`, unchanged, not a second parallel demo dataset). A real, tested, callable unit; scheduling it periodically is an operational decision for whoever deploys a read-only instance, not made here. `BLUEPRINT_CONTENT_PACKS`/`BlueprintContentPack` are now part of the package's public exports.
3003
+
3004
+ Actual public deployment of a playground or the project site is explicitly out of scope: it is an irreversible action toward the outside world requiring resources only a human holds, per this project's standing autonomy rule.
3005
+
3006
+ Also new: `@cogenta/project-site` (private, unpublished) — a small, real presentation site for the Cogenta project itself, built through the same content model and `renderPage`/`renderBlock` pipeline any installed site uses, with real content drawn from `docs/00-vision.md` and this session's own documentation.
3007
+
3008
+ - [`3184163`](https://github.com/cogenta-cms/cogenta/commit/318416355a83d88828786344e1ff80e1b113c564) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `runPlugin` (L7 task 6) now enforces the lot's own words in full: "un plugin
3009
+ qui dépasse son temps ou sa mémoire est tué et désactivé, avec alerte. Il ne
3010
+ peut pas faire tomber le CMS."
3011
+
3012
+ - A worker failure is now classified (`IsolatedRunResult.reason`:
3013
+ `'timeout' | 'memory' | 'crash'`) — `'memory'` is detected from Node's
3014
+ real `resourceLimits` heap-violation error message, `'timeout'` from the
3015
+ existing kill switch, everything else is `'crash'`.
3016
+ - Only a `'timeout'` or `'memory'` violation disables the plugin — an
3017
+ ordinary thrown error never does. Disablement is real and persisted
3018
+ (`createPluginDisableStore`, `cogenta_plugin_disabled` table, mirroring
3019
+ `cogenta_plugin_grants`'s `ensurePluginTables` pattern). `runPlugin` now
3020
+ requires a `disableStore` and refuses (`PLUGIN_DISABLED`, a new
3021
+ `@cogenta/core` error code) to even spawn a worker for an already-disabled
3022
+ plugin — checked before every run, not just after a violation.
3023
+ - The "avec alerte" half is a structural callback (`onPluginDisabled`), not
3024
+ a hard dependency on `@cogenta/channels` or any specific transport —
3025
+ wiring a disablement to a real notification is an integration decision
3026
+ for whatever assembles a site.
3027
+ - Proven by real, worker-based tests: a genuine heap-exhaustion fixture
3028
+ trips the real `resourceLimits` ceiling and is classified `'memory'`; the
3029
+ host process is proven to survive and remain usable (a follow-up run
3030
+ succeeds immediately after either violation type); a disabled plugin's
3031
+ next run attempt is refused before a worker is spawned; a human can
3032
+ re-enable a disabled plugin.
3033
+
3034
+ - [`1f2eecc`](https://github.com/cogenta-cms/cogenta/commit/1f2eecc754286c9e140511634b465a6536f99f25) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Adds L7 task 9: real signature verification for registry-sourced plugins,
3035
+ per "## Signature" (docs/lots/L7-extensibilite.md): "Une signature invalide
3036
+ bloque, sans possibilité de passer outre depuis l'interface."
3037
+
3038
+ - `packages/plugins/src/signing/` — real Ed25519 signing/verification via
3039
+ `node:crypto` (no new dependency): `generateSigningKeyPair`, `signManifest`
3040
+ (signs a deterministic, sorted-key canonicalization of the manifest),
3041
+ `verifyManifestSignature`/`verifyPluginSignature` (verifies against any
3042
+ of a list of trusted public keys), `readSignatureFile` (a signature travels
3043
+ as a sibling `<manifest>.sig` file, never embedded in the manifest shape).
3044
+ - `TRUSTED_REGISTRY_PUBLIC_KEYS` starts empty — no real plugin registry
3045
+ exists yet (pre-alpha), so every `registry`-source plugin fails
3046
+ verification by default rather than trusting a placeholder key.
3047
+ - `loadPlugin` (L7 task 2) now calls `resolveSignatureStatus` for every
3048
+ resolution: a `registry`-source plugin with a missing or invalid signature
3049
+ is hard-refused (`PLUGIN_SIGNATURE_MISSING`/`PLUGIN_SIGNATURE_INVALID`)
3050
+ before any plugin code is imported — there is no parameter anywhere that
3051
+ lets a caller force past this. A `local`/`git`-source plugin is allowed
3052
+ unsigned ("mode développement") and now carries a real `devMode: true`
3053
+ flag on `ResolvedPlugin` (plus `signatureVerified: boolean`) for a future
3054
+ admin banner to render as the lot's "avertissement permanent."
3055
+
3056
+ Two new `@cogenta/core` error codes: `PLUGIN_SIGNATURE_MISSING`,
3057
+ `PLUGIN_SIGNATURE_INVALID`.
3058
+
3059
+ - [`6ce944f`](https://github.com/cogenta-cms/cogenta/commit/6ce944ffac8e947a979b8dc46a64ee3699b0b402) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add one error code for L4's privacy layer: `PRIVACY_NO_DATA_LEAVES_VIOLATION`
3060
+ (a run configured with `privacyPolicy.enabled: true` tried to call a
3061
+ provider outside its declared local allowlist).
3062
+
3063
+ - [`fc13c44`](https://github.com/cogenta-cms/cogenta/commit/fc13c4484c1c01a64b23941622e8308731fd937e) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add four error codes for L4's LLM provider adapters: `PROVIDER_UNKNOWN`,
3064
+ `PROVIDER_REQUEST_FAILED`, `PROVIDER_RESPONSE_INVALID`, `PROVIDER_RATE_LIMITED`.
3065
+
3066
+ - [`f5b0d4c`](https://github.com/cogenta-cms/cogenta/commit/f5b0d4cd8b7a81b36f8c539b38a412b893cb125c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the `PROVIDER_TIMEOUT` error code, thrown by `@cogenta/agents`' execution
3067
+ loop when a model call does not respond within its configured `timeoutMs`.
3068
+
3069
+ - [`1041c9f`](https://github.com/cogenta-cms/cogenta/commit/1041c9fb8c39872350786e5dc5b8a4f84e2b3ff7) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `MediaStore` to `@cogenta/core` — the persisted metadata record for a media
3070
+ asset (alt text, decorative flag with a required justification, focal point,
3071
+ dimensions, storage key), backed by one SQL table played against SQLite,
3072
+ Postgres and MySQL through the same contract, the same shape as the degraded
3073
+ job queue. Nothing wired this to a route yet: L2 task 11 (médiathèque) is
3074
+ still in progress, and this is its data layer.
3075
+
3076
+ Alt text policy is enforced in the store, not left to a caller to remember:
3077
+ a non-decorative asset needs non-empty alt text, and a decorative one needs a
3078
+ justification, writing `alt=""` regardless of what was passed — matching
3079
+ L2-admin.md's own rule that a decorative image never gets an invented
3080
+ description.
3081
+
3082
+ `sniffImageFormat`/`describeContainer` (real-type detection by magic bytes,
3083
+ never by filename or `Content-Type`) moved from `@cogenta/render` into
3084
+ `@cogenta/core`, since the upcoming media upload route needs the exact same
3085
+ check and depending on `@cogenta/render` for four byte-signature functions
3086
+ would pull in its Astro/sharp integration for no reason. `@cogenta/render`
3087
+ re-exports both from its own `images` module, so no call site there changes.
3088
+
3089
+ ADR-0017 records the SVG policy this data layer assumes: refused by default,
3090
+ never served raw, until a reviewed sanitizer exists.
3091
+
3092
+ - [`ed7e7d1`](https://github.com/cogenta-cms/cogenta/commit/ed7e7d1cd73eedff8877c974938b7134bd24ac3b) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the migration engine: tracking table, apply, rollback and status.
3093
+
3094
+ `down()` is a required method, not an optional one. AGENTS.md says migrations are always
3095
+ reversible, and a type that permits an irreversible migration turns that rule into a
3096
+ suggestion.
3097
+
3098
+ A destructive migration refuses to run without **both** an explicit confirmation and a
3099
+ verified backup, and the refusal names what each one will do to existing data so the
3100
+ confirmation is informed rather than reflexive.
3101
+
3102
+ An applied migration that changed is refused rather than re-run or ignored: two
3103
+ environments that ran different SQL under the same id differ in ways nothing records.
3104
+ `status()` reports the mismatch without throwing, so a diagnosis can still run.
3105
+
3106
+ Migrations take an exclusive lock, so two deployments cannot migrate at once — the
3107
+ primary key does the work, and a lock left by a crashed process is taken over after
3108
+ fifteen minutes. Each migration runs in a transaction where the database has
3109
+ transactional DDL. **MySQL does not**, so a failed migration there may be half applied;
3110
+ the engine says so in the error instead of claiming a rollback that never happened.
3111
+
3112
+ - [`fe1e7b6`](https://github.com/cogenta-cms/cogenta/commit/fe1e7b693d3a5eb8635e783a75863f5613712fb4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the storage driver interface, the `local` implementation and their contract suite.
3113
+
3114
+ Object keys are validated against a whitelist of allowed characters per segment rather
3115
+ than a blacklist of dangerous ones — keys arrive from uploads, imports and plugins, and
3116
+ a blacklist loses to URL encoding, backslashes and Unicode look-alikes. Every operation
3117
+ validates, so a traversal attempt raises instead of quietly reporting "not found".
3118
+
3119
+ The `local` driver keeps objects and their metadata in two parallel trees. Storing the
3120
+ metadata next to the object would make it addressable as an object itself: readable
3121
+ under a guessable key, overwritable through a forged one, and colliding with any key
3122
+ that happened to end in the sidecar suffix.
3123
+
3124
+ Signed URLs are HMAC-signed and verified in constant time. Without
3125
+ `COGENTA_STORAGE_SIGNING_KEY` the driver generates a per-process key and says so through
3126
+ `health()`, rather than silently issuing URLs that stop working after a restart.
3127
+
3128
+ `StorageDriver` also gains `head()`: the content type is supplied by the caller and
3129
+ cannot be recovered later, so an interface with no way to read it back would lose it.
3130
+
3131
+ - [`a609efa`](https://github.com/cogenta-cms/cogenta/commit/a609efa46060a35b048a24e7d03b7bbde414b7a4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add three error codes for L4's reversibility layer:
3132
+ `RECEIPT_UNKNOWN` (reverting a receipt id that does not exist),
3133
+ `RECEIPT_ALREADY_REVERTED` (reverting a receipt a second time), and
3134
+ `RECEIPT_NOT_REVERTIBLE` (the matching tool has no `revert()` available in
3135
+ the current run).
3136
+
3137
+ - [`32dc81a`](https://github.com/cogenta-cms/cogenta/commit/32dc81adac441ecc0b105c4da02e9064ead09b99) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the S3 storage driver as the optimal tier, verified against MinIO.
3138
+
3139
+ The AWS SDK is an optional peer loaded through a dynamic import: a site storing media on
3140
+ disk installs none of it, and the published type declarations do not reference it. A
3141
+ buffer goes through `PutObject`; a stream goes through multipart `Upload`, so a large
3142
+ video is never buffered in memory to be stored.
3143
+
3144
+ `forcePathStyle` is set whenever a custom endpoint is configured. MinIO, R2 and most
3145
+ self-hosted gateways serve buckets as a path rather than a subdomain, and assuming
3146
+ virtual-host style breaks all of them with what looks like a DNS error.
3147
+
3148
+ The contract suite also caught a parity break: an object stored with no declared content
3149
+ type reads back as `application/octet-stream` from S3 but was `undefined` from the local
3150
+ driver, which would have made the two impossible to substitute when serving media. Both
3151
+ now return the HTTP default.
3152
+
3153
+ - [`f0915d5`](https://github.com/cogenta-cms/cogenta/commit/f0915d5b3040512560477cfbb95729a6e69a3f3c) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the `deps.scan` tool: SBOM → OSV.dev correlation (only versions
3154
+ genuinely installed and affected, matched by OSV's own query semantics)
3155
+ → EPSS lookup → exploitability assessment crossing CVSS and EPSS →
3156
+ imposed-format security report (what's affected / what an attacker
3157
+ could do / is the site exposed / what's proposed / what happens if
3158
+ nothing is done).
3159
+
3160
+ Two new `@cogenta/core` error codes: `SECURITY_OSV_QUERY_FAILED` and
3161
+ `SECURITY_EPSS_QUERY_FAILED`.
3162
+
3163
+ - [`269c38b`](https://github.com/cogenta-cms/cogenta/commit/269c38b4df5bae381cadbfa85d5c6fe12353e177) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `deps.patch` (opens a pull request bumping one dependency to a fixed
3164
+ version — never modifies anything directly; `revert` closes the PR
3165
+ without merging) and `securityAgent`, the frozen `AgentDeclaration`
3166
+ tying `deps.scan`/`deps.patch` together with the lot's default autonomy
3167
+ (`deps.scan` autonomous, `deps.patch` proposed).
3168
+
3169
+ One new `@cogenta/core` error code: `SECURITY_DEPENDENCY_NOT_FOUND`.
3170
+
3171
+ - [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add slugs, routing, automatic 301 redirects and scheduled publication to `@cogenta/schema`.
3172
+
3173
+ **Slugs.** `slugify` transliterates with `normalize('NFD')` and a written-down table for
3174
+ the letters Unicode does not decompose — "ß", "æ", "ø" — so it needs no dependency and no
3175
+ data file. `deriveSlug` reads the source named by `f.slug({ from: 'title' })`, keeps a
3176
+ slug the editor typed by hand, and resolves collisions with a `-2`, `-3` suffix that
3177
+ stays inside the length budget rather than growing past the column width. Uniqueness is
3178
+ scoped **per collection and per locale**, which is what ADR-0014 implies: the French and
3179
+ the English article are two entries, and both are legitimately `/mon-article` under their
3180
+ own prefix.
3181
+
3182
+ **Redirects.** Changing the slug of a **published** entry now writes a 301 with nobody
3183
+ asking for it, and the table is listable, filterable and deletable. Two properties are
3184
+ enforced at write time rather than left to whoever reads the table later:
3185
+
3186
+ - chains are flattened — renaming a page twice leaves one hop, not two, so a visitor
3187
+ never pays for the site's edit history;
3188
+ - loops are refused with `CONTENT_REDIRECT_LOOP`, and moving a page back to its old URL
3189
+ is expressed as `release()` rather than as a cycle the store quietly repairs.
3190
+
3191
+ A draft that changes slug records nothing: nobody could reach the old URL, and a redirect
3192
+ from an unreachable path is a row that only ever confuses.
3193
+
3194
+ **Routing.** `matchPath` resolves a URL against `routing.pattern`, with or without the
3195
+ locale prefix, and `buildPath` goes the other way. `resolveUrl` answers `entry`,
3196
+ `redirect` or `notFound` — content first, redirects second, so a stale rule can never
3197
+ shadow a page that is live.
3198
+
3199
+ **Scheduled publication.** An entry in `status: 'scheduled'` becomes a job in the L0
3200
+ queue, and the whole module is written against `QueueDriver` and nothing else. It
3201
+ therefore works on the `database` queue — the driver with no worker of its own, drained
3202
+ by a cron calling `tick()`. On a cron every five minutes, a page scheduled for 09:00 goes
3203
+ live between 09:00 and 09:05; that is the honest promise of a host without a worker, and
3204
+ the handler logs the lateness so the question can be answered when it is asked. An entry
3205
+ whose hour passed while the site was down publishes on the next tick instead of being
3206
+ skipped.
3207
+
3208
+ `@cogenta/core` gains five error codes for the above: `CONTENT_SLUG_INVALID`,
3209
+ `CONTENT_SLUG_TAKEN`, `CONTENT_ROUTE_INVALID`, `CONTENT_REDIRECT_LOOP` and
3210
+ `CONTENT_SCHEDULE_INVALID`. Adding a code is a minor change; no existing code changed
3211
+ meaning.
3212
+
3213
+ - [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the content persistence layer: typed CRUD, drafts, versions, diff and i18n, portable
3214
+ across Postgres, MySQL, MariaDB and SQLite.
3215
+
3216
+ `createContentStore({ db, collection })` gives a collection its create/read/update/
3217
+ delete/list, plus `publish`, `unpublish`, `history`, `readVersion`, `restore`, `diff`,
3218
+ `translations` and `resolveLocale`. `createSchemaTables(db, collections)` builds the
3219
+ physical schema the store expects — the same DDL the migration generator will emit, so
3220
+ the two cannot drift.
3221
+
3222
+ The entry table holds the **live** state, which is what the public renderer reads. With
3223
+ `versioning.drafts`, editing a published entry writes a version row and leaves the live
3224
+ row alone: a draft is unreachable through `read(id)` because it is not there, not
3225
+ because a filter remembered to exclude it. Publishing moves the live row onto the
3226
+ working version. `versioning.keep` bounds the history, and the live version is never
3227
+ pruned.
3228
+
3229
+ Pagination is by keyset cursor, never by offset: a cursor is the sort value and the id of
3230
+ the last row handed out, so entries inserted concurrently cannot shift a window and make
3231
+ a reader see the same entry twice or miss one. A cursor taken under one ordering is
3232
+ refused under another.
3233
+
3234
+ Identifiers are UUIDv7 minted by the application (ADR-0015) — no `RETURNING`, no
3235
+ `insertId`, and content keeps its identity across dev, staging and production. Blocks are
3236
+ one row each, ordered, with a stable `_key` (contract A), so "which pages use this
3237
+ medium", cache-tag invalidation and per-block RAG chunking stay possible. Content is one
3238
+ entry per language (ADR-0014): `status`, `publishedAt` and `version` are per language,
3239
+ and a missing locale renders through one of three explicit strategies — show the
3240
+ original, hide it, or report it missing.
3241
+
3242
+ Core gains three error codes — `CONTENT_NOT_FOUND`, `CONTENT_INVALID` and
3243
+ `CONTENT_CONFLICT` — so the content layer reports what failed with a code callers can
3244
+ branch on, instead of borrowing `CONFIG_INVALID` for an editor's mistake.
3245
+
3246
+ - [`11d592b`](https://github.com/cogenta-cms/cogenta/commit/11d592bbca9cea415c95aa0edb4a85aef8b05174) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add two error codes for L4's skills layer:
3247
+ `SKILL_UNKNOWN` (loading a skill name that was never installed) and
3248
+ `SKILL_DEFINITION_INVALID` (a `SKILL.md` has no frontmatter block, or is
3249
+ missing `name`/`version`/`description`).
3250
+
3251
+ - [`64b43fb`](https://github.com/cogenta-cms/cogenta/commit/64b43fb661784c855c1375dfcf995999198e93d3) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the driver system: interface, registry, selection and health reporting.
3252
+
3253
+ `createDriverRegistry` holds the implementations of one infrastructure need and picks
3254
+ between them by two different rules. When the configuration **names** a driver, that
3255
+ driver is used and any failure is fatal — starting on the filesystem because Redis was
3256
+ down, and saying nothing, would be a silent downgrade of someone's site. When it names
3257
+ nothing (or `auto`), the first available driver wins in tier order, and failures fall
3258
+ through to the next one, so `npm create cogenta` produces a working site with nothing
3259
+ else installed.
3260
+
3261
+ Every selection carries a `reason` and the list of what was `skipped` and why, so the
3262
+ admin and `cogenta doctor` can state "job queue: database (degraded), because Redis is
3263
+ absent". A driver whose availability probe throws counts as absent rather than crashing
3264
+ startup, and `dispose()` is idempotent because shutdown paths overlap.
3265
+
3266
+ - [`c93a5f7`](https://github.com/cogenta-cms/cogenta/commit/c93a5f709bce8b380c270a1b4ef31dac86293535) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `auth.signingKey` to the resolved configuration, read from
3267
+ `COGENTA_AUTH_SIGNING_KEY` — the key `@cogenta/auth`'s login ticket needs, and a real
3268
+ secret rather than a config-file field (rule R7): there is no `auth` section in the input
3269
+ schema at all, so writing one in `cogenta.config.ts` is rejected as an unrecognised key,
3270
+ not merely a forbidden one.
3271
+
3272
+ - [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the rendering layer: `@cogenta/render`, `@cogenta/theme-canonical` and `@cogenta/seo`.
3273
+
3274
+ A theme reads content through an HTTP client carrying a read-only token, never through
3275
+ the data layer (ADR-0016), and the isolation is checked at install rather than documented
3276
+ and hoped for. A hostile-theme fixture proves the refusal against every route in: a bare
3277
+ `fs` alias, a subpath import, a template-literal dynamic import, `createRequire`, an
3278
+ import inside a `<script>`, and a `node:fs` alias smuggled through `package.json`
3279
+ `imports`. The inverse guard matters as much — a theme whose prose contains `don't`, a
3280
+ class named `process` and a commented-out import yields zero findings.
3281
+
3282
+ The canonical theme implements the twelve blocks with no JavaScript at all, asserted:
3283
+ no script tag, no `on*` attribute, no `client:*` directive. Heading levels are read from
3284
+ the block vocabulary rather than restated, so a titleless `featureGrid` keeps its items
3285
+ at `h2` and no level is skipped. `consentRequired` suppresses even the provider
3286
+ thumbnail, because a thumbnail already leaks the visitor's IP.
3287
+
3288
+ Skins validate as hard refusals: AA contrast on every declared pair with no epsilon on
3289
+ the threshold, a monotonic type scale, no missing and no unknown token, and
3290
+ `prefers-reduced-motion` honoured. A token value containing CSS syntax is refused — a
3291
+ skin is a shareable JSON file interpolated into a stylesheet, and without that check it
3292
+ is code rather than data.
3293
+
3294
+ SEO derives JSON-LD from the schema, keeps `hreflang` reciprocal by construction, and
3295
+ blocks indexing on the working state as well as on draft status: a feed rendered from
3296
+ the working face ships unreviewed edits, which is the same leak as a draft and far
3297
+ harder to notice.
3298
+
3299
+ - [`163d88b`](https://github.com/cogenta-cms/cogenta/commit/163d88bc594b457a06e19ce39e4fbe9e4693e4d8) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add two error codes for L4's sub-agent layer:
3300
+ `AGENT_SUBAGENT_UNKNOWN` (a declared sub-agent name is not in the agent
3301
+ set) and `AGENT_SUBAGENT_TOOLS_NOT_SUBSET` (a sub-agent declares a tool
3302
+ its parent was not granted).
3303
+
3304
+ - [`73acd6f`](https://github.com/cogenta-cms/cogenta/commit/73acd6f40a6c1904fde717891f04079d930a0e43) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the cache drivers: `memory` and `file`, plus their single contract suite.
3305
+
3306
+ `invalidateTags` is mandatory in every implementation, servers or not — content caching
3307
+ is only correct if publishing can drop every page that embedded the changed content, and
3308
+ bolting that on later would mean rewriting each driver.
3309
+
3310
+ Values round-trip through serialisation in `memory` too, not just on disk, so a caller
3311
+ cannot mutate the cache by keeping the reference on one driver and not on another. Both
3312
+ drivers run the same contract file, which is what makes them substitutable rather than
3313
+ merely similar.
3314
+
3315
+ The `file` driver hashes keys into filenames rather than escaping them, writes through a
3316
+ uniquely named temporary file and an atomic rename, and retries the rename on the EPERM
3317
+ and EBUSY that Windows returns when another handle holds the target. A corrupted entry
3318
+ reads as a miss: a cache that throws is worse than a cache that forgets.
3319
+
3320
+ - [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/render`: the Astro integration, the `RenderContext`, and the theme installation check.
3321
+
3322
+ - `cogentaTheme()` is an Astro integration that resolves the active theme from the
3323
+ configuration, aliases its sources as `@theme`, and exposes its manifest through the
3324
+ virtual module `virtual:cogenta/theme`. The content token never enters Vite's module
3325
+ graph.
3326
+ - `createRenderContext()` builds exactly the `RenderContext` contract D freezes at
3327
+ `theme@1.0` — `site`, `locale`, `url`, `t()`, `image()`, `link()`, `content` — and
3328
+ nothing else.
3329
+ - `ctx.content` is an HTTP client to the content API carrying a read-only token
3330
+ (ADR-0016). It links against neither `@cogenta/schema` nor a database driver, so a
3331
+ theme cannot reach a draft even by asking.
3332
+ - `verifyTheme()` refuses a theme at installation, naming file, line and import, when it
3333
+ does not declare the twelve blocks of the vocabulary, or when it reaches for a
3334
+ forbidden module — directly, through the unprefixed spelling of a builtin, through a
3335
+ subpath, through an unreadable dynamic `import()`, through CommonJS, or through a
3336
+ `package.json` alias. Refused, not warned.
3337
+
3338
+ `@cogenta/core` gains the error codes `THEME_NOT_FOUND`, `THEME_INVALID`,
3339
+ `THEME_BLOCK_MISSING`, `THEME_IMPORT_FORBIDDEN` and `CONTENT_API_FAILED`.
3340
+
3341
+ - [`696c163`](https://github.com/cogenta-cms/cogenta/commit/696c163c05bb981413e52af74d63dcbcbe72c99e) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add five error codes for L4's tool registry: `TOOL_DEFINITION_INVALID`,
3342
+ `TOOL_DUPLICATE`, `TOOL_UNKNOWN`, `TOOL_INPUT_INVALID`, `TOOL_OUTPUT_INVALID`.
3343
+
3344
+ - [`d5896bb`](https://github.com/cogenta-cms/cogenta/commit/d5896bb8bbabb43873e82deb1acfdb818def201b) Thanks [@georgesmomo](https://github.com/georgesmomo)! - New package `@cogenta/import`: `cogenta import wordpress <file.xml>` (L9 task
3345
+ 6). Imports a WordPress "Export All Content" WXR file — posts, pages,
3346
+ categories, tags, media (downloaded and re-stored through `MediaStore`/
3347
+ `StorageDriver`), authors (as real, credential-less users), approved comments,
3348
+ postmeta (carried as opaque `f.json()` `customFields`, contract A has no
3349
+ free-form field kind), Gutenberg blocks converted to the block vocabulary
3350
+ (`prose`/`mediaFigure`/`quote`/`gallery`/`embed`) where a mapping exists, and
3351
+ 301 redirects from each entry's old permalink (`reason: 'import'`, the
3352
+ `@cogenta/schema` redirect store's own case for this). Every WXR reader is a
3353
+ zero-dependency, WXR-scoped XML tokenizer (`deps-auditor` rejected
3354
+ `fast-xml-parser`: a single-maintainer seven-package split published the same
3355
+ day, and a general parser's DTD support is an unnecessary XXE surface for a
3356
+ file of unknown provenance) — a document declaring `<!DOCTYPE ... ENTITY` is
3357
+ rejected outright.
3358
+
3359
+ Nothing that cannot be converted is silently dropped: an unmappable Gutenberg
3360
+ block, a dead media URL, an author with no email, a trashed post — every one
3361
+ of them lands in the returned `ConversionReport` (`imported`/`skipped`/
3362
+ `unconvertedBlocks`/`warnings`), which `cogenta import wordpress` prints. The
3363
+ command exits `0` even with items reported as unconverted — a reported
3364
+ partial import is the intended outcome for a real-world export, not a
3365
+ failure — and only exits non-zero when the file cannot be read or parsed at
3366
+ all.
3367
+
3368
+ Two new `@cogenta/core` error codes: `IMPORT_WXR_PARSE_FAILED`,
3369
+ `IMPORT_WXR_UNSAFE_DOCUMENT`.
3370
+
3371
+ - [`693697e`](https://github.com/cogenta-cms/cogenta/commit/693697ed41174c027c5acaa43abb3a9c0e41bbab) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the Redis cache driver, as the optimal tier.
3372
+
3373
+ `@redis/client` is an **optional peer dependency**, loaded through a dynamic import. A
3374
+ site that does not want Redis never installs it, `pnpm install` stays free of runtime
3375
+ dependencies, and the registry simply falls through to the file driver when the package
3376
+ or the server is absent. The published type declarations do not reference it either: the
3377
+ driver describes the slice of the client API it uses structurally.
3378
+
3379
+ Keys are namespaced, and `clear()` walks them with `SCAN` rather than `FLUSHDB` — the
3380
+ Redis instance may be serving other things, and a cache driver that wipes someone else's
3381
+ data is an incident, not a clear. Expiry is written as `PX` so Redis can reclaim memory
3382
+ on its own clock, but the authoritative check stays on read, which is what keeps this
3383
+ driver's behaviour identical to the others.
3384
+
3385
+ - [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/blocks`: the twelve-block semantic vocabulary of contract B.
3386
+
3387
+ `defineBlock` declares a block manifest — `name`, `version`, `schema`, `runtime`,
3388
+ `fallback`, `a11y` — and compiles it into a Zod validator. The twelve blocks of
3389
+ `blocks@1.0` ship registered and ready: `hero`, `prose`, `mediaFigure`,
3390
+ `featureGrid`, `cta`, `gallery`, `quote`, `faq`, `stats`, `logos`,
3391
+ `collectionList` and `embed`.
3392
+
3393
+ - `parseBlock` / `parseBlocks` validate on write and refuse anything
3394
+ presentational: HTML in a text field, an unrecognised `className`, a `style`
3395
+ value. The error names the block and the field.
3396
+ - `loadBlock` / `loadBlocks` migrate a block whose schema version has moved on,
3397
+ one version step at a time, and report `migrated` so the caller writes the
3398
+ result back. A block's `_key` survives the migration by construction.
3399
+ - Register your own steps on a `BlockMigrationRegistry`; a missing step is a
3400
+ refusal, never a silent partial migration.
3401
+
3402
+ `@cogenta/core` gains the `BLOCK_UNKNOWN`, `BLOCK_INVALID`,
3403
+ `BLOCK_DEFINITION_INVALID` and `BLOCK_MIGRATION_FAILED` error codes.
3404
+
3405
+ ### Patch Changes
3406
+
3407
+ - [`b18a02c`](https://github.com/cogenta-cms/cogenta/commit/b18a02c3f5638520794db83bd1adfdb246a4f839) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `cogenta migrate` — `status`, `up` and `down` — over the existing migration engine.
3408
+
3409
+ `status` lists every migration with the date and duration of its run, and marks the ones
3410
+ that changed after they were applied here. That last case exits non-zero: two
3411
+ environments that ran different SQL under the same id is the worst state to debug, and a
3412
+ deployment script has to notice it rather than read it.
3413
+
3414
+ Migrations are plain ESM files in a `migrations/` directory next to the configuration
3415
+ file, default-exporting an object with `up(tx)` and `down(tx)`. They are ordered by file
3416
+ name, the id defaults to the file name, and the checksum is a hash of the file itself —
3417
+ so a migration edited after it ran is detected without anyone maintaining a second
3418
+ number. A project with no `migrations/` directory has zero migrations, which is not an
3419
+ error: L0 ships no business schema at all.
3420
+
3421
+ A destructive migration still needs `--confirm-destructive` **and** `--backup-verified`.
3422
+ The engine already refused without both; the CLI now makes the refusal actionable by
3423
+ naming each destructive migration and printing its declared impact, instead of asking
3424
+ the operator to go and read the files.
3425
+
3426
+ Core fix, found by running the command from a subdirectory: a relative path in a config
3427
+ file is now resolved against **that file**, not against the shell's working directory.
3428
+ `cogenta migrate status` run from `src/` used to open an empty `./site.db` next to `src/`
3429
+ and report an already-migrated database as entirely pending. The same applies to
3430
+ `cache.path` and `storage.path`. Absolute paths, server URLs and `:memory:` are
3431
+ untouched, and configuration that comes from the environment alone is unaffected.
3432
+
3433
+ - [`f870177`](https://github.com/cogenta-cms/cogenta/commit/f8701772440a4b3a7d0726b0836b94b7c1b57344) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Adds a complete Discord channel adapter (L6 task 10): outbound messages
3434
+ rendered as real embeds with button components, inbound message/interaction
3435
+ handling routed through the existing identity-linking and human-permission
3436
+ authorization gate (never a parallel path), a real Gateway WebSocket client
3437
+ with deterministic heartbeat scheduling, and 429 rate-limit handling using
3438
+ Discord's actual `retry_after` value. One new `@cogenta/core` error code:
3439
+ `CHANNEL_DISCORD_API_ERROR`.
3440
+
3441
+ - [`d10724c`](https://github.com/cogenta-cms/cogenta/commit/d10724cb238399bf7203fff0bc151a832c555ad4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `@cogenta/fleet` gains site-side telemetry emission (`packages/fleet/src/agent/`) — the closed, honest shape of what a site is allowed to send to the control plane, per the lot's own "## Ce qui remonte, et ce qui ne remonte pas."
3442
+
3443
+ - `TelemetryPayload` is a closed type: only the fields the lot doc names
3444
+ (`installedVersions`, `sbomFingerprint`, `openCves`, `coreWebVitalsAggregate`,
3445
+ `availability`, `backups`, `certificateExpiry`, `adminAccounts`,
3446
+ `aggregatedErrors`) exist on it — no `content`/`media`/`memory`/`logs`
3447
+ field is representable at all. `sbomFingerprint`, `openCves`,
3448
+ `coreWebVitalsAggregate` and `adminAccounts` are wired to real, existing
3449
+ data sources in this codebase (`@cogenta/agents-builtin`'s security/
3450
+ performance agents, `@cogenta/auth`'s real user/credential model); the
3451
+ rest are honest shape-only placeholders — no real backup mechanism,
3452
+ certificate-expiry check, uptime monitor, or error-aggregation sink
3453
+ exists anywhere yet, and this task does not fabricate one.
3454
+ - `assertNoForbiddenFields` is a real, defense-in-depth runtime scan for the
3455
+ same forbidden list, catching a leak past a loosely-typed call site that
3456
+ TypeScript alone wouldn't stop — the literal "vérification exhaustive de
3457
+ ce qui sort d'un site" security test the lot names.
3458
+ - `signTelemetryPayload`/`verifyTelemetrySignature` reuse `@cogenta/plugins`'
3459
+ generalized Ed25519 primitive (task 9/12) — the same one L8 task 1's
3460
+ pairing already uses — and refuse to sign a payload carrying a forbidden
3461
+ field at all, rather than catching it only closer to the network boundary.
3462
+ - `fingerprintSbom` hashes the real SBOM via the same canonical, sorted-key
3463
+ content-signing helper, with a real bug fixed during this task's own
3464
+ testing: `canonicalizeContent` sorts object keys but not array element
3465
+ order, so two functionally-identical SBOMs built from a `dependencies`
3466
+ record whose keys simply iterate in a different order would otherwise
3467
+ fingerprint differently — the entries are now sorted by name before
3468
+ canonicalizing.
3469
+
3470
+ One new `@cogenta/core` error code: `FLEET_TELEMETRY_FORBIDDEN_FIELD`.
3471
+
3472
+ - [`22ec8de`](https://github.com/cogenta-cms/cogenta/commit/22ec8deec494a2925a943550fcf3c5e1689eb40e) Thanks [@georgesmomo](https://github.com/georgesmomo)! - The MySQL/MariaDB driver's `transaction()` now retries automatically (up to
3473
+ 3 attempts) when InnoDB reports `ER_LOCK_DEADLOCK`. A deadlock victim is not
3474
+ an application bug — MySQL expects the losing transaction to restart from
3475
+ scratch — but until now the raw error propagated straight to the caller,
3476
+ so two concurrent writers touching the same rows (two agents publishing at
3477
+ once, not just @cogenta/schema's own ten-concurrent-insert test) could
3478
+ surface a hard failure instead of one of them transparently retrying.
3479
+
3480
+ Found via CI: @cogenta/schema's cursor-pagination-under-concurrent-insert
3481
+ integration test was deterministically deadlocking on both the mysql and
3482
+ mariadb dialects (they share this same driver), not a flake — reproduced
3483
+ on two separate CI runs before the fix.
3484
+
3485
+ - [`e8692eb`](https://github.com/cogenta-cms/cogenta/commit/e8692eba9f47a7a7eee176058f4638abec71dce0) Thanks [@georgesmomo](https://github.com/georgesmomo)! - L7 task 4: the real, capability-gated SDK a sandboxed plugin actually calls.
3486
+ A small starter set — `content.read`, `http.fetch:<domain>`,
3487
+ `storage.read:<prefix>`/`storage.write:<prefix>` — each backed by a real
3488
+ host-side handler (`packages/plugins/src/host/capabilities.ts`) reached
3489
+ through a real bidirectional RPC extension of task 3's message protocol
3490
+ (`sdk-call`/`sdk-result`/`sdk-error`).
3491
+
3492
+ Every handler re-verifies the SPECIFIC request (the exact requested domain,
3493
+ the exact storage key) against the SPECIFIC granted capability parameter —
3494
+ never just "was this capability name granted at all." A plugin granted
3495
+ `http.fetch:api.example.com` cannot use its own SDK method to reach a
3496
+ different domain; a plugin granted `storage.write:plugins/<name>` cannot
3497
+ escape that prefix, including via `../` traversal.
3498
+
3499
+ "Une méthode non accordée est absente de l'objet SDK, pas seulement
3500
+ refusée" (explicit acceptance criterion) is enforced structurally: the
3501
+ guest-side sandbox (`packages/plugins/src/guest/sandbox-entry.mjs`) only
3502
+ ever assigns a method key onto the `sdk` object for a capability actually
3503
+ present in the granted list — a non-granted method is a genuinely missing
3504
+ object key, not a present function that throws.
3505
+
3506
+ One new `@cogenta/core` error code: `PLUGIN_CAPABILITY_REFUSED`.
3507
+
3508
+ - [`6a84427`](https://github.com/cogenta-cms/cogenta/commit/6a84427da789abdce1f61feeef7c1ff5bc7fb9f5) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `create-cogenta` — AI skin generation with hard-refusal validation (L9 task
3509
+ 7). When an LLM provider is configured with a valid key and a free-text site
3510
+ description is given, the `blog` blueprint's `theme.tokens.json` is generated
3511
+ from that description instead of copying the theme's default: the model is
3512
+ asked for contract D's token JSON only (never CSS), and every candidate is
3513
+ checked by `@cogenta/render`'s existing `validateSkin` — reused wholesale,
3514
+ not reimplemented — in hard-refusal mode (AA contrast on every pair, a
3515
+ strictly monotone type scale, the full closed token set, `motion.reduced`).
3516
+ On a rejection, the thrown `CogentaError`'s `message`/`hint` become the next
3517
+ attempt's correction prompt, for three attempts. A successful candidate is
3518
+ rendered on three real preview pages (through the same generic
3519
+ `renderPage`/`renderBlock` pipeline a live site uses) written to
3520
+ `.cogenta/skin-preview/`, and the installer offers accept, regenerate, or
3521
+ fall back to the default — bounded so a non-interactive `--yes`/`--config`
3522
+ run never loops. Every outcome — generated and accepted, regenerated,
3523
+ fallen back after failed validation, or never offered — is reported by name
3524
+ in the install recap; nothing is silent.
3525
+
3526
+ Scoped to the `blog` blueprint only, the one blueprint that writes a
3527
+ `theme.tokens.json` today. Regenerating a skin after install (`cogenta skin
3528
+ generate`) is explicit CLI surface the lot doc lists under a later task (L9
3529
+ task 9) and is not built here.
3530
+
3531
+ One new `@cogenta/core` error code: `SKIN_GENERATION_RESPONSE_NOT_JSON`, for
3532
+ a model response that is not a single JSON object.