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