@cerefox/memory 1.15.2 → 1.16.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.
@@ -295,6 +295,22 @@ states the count, with a Stop button and a per-document audit entry; there is no
295
295
  server-side "purge everything" endpoint, on purpose, because `/api/v1` is
296
296
  reachable by anything holding the local key.
297
297
 
298
+ **Trash auto-purge (v1.16.0, opt-in) does not weaken this either, and here is exactly
299
+ why.** With `trash_auto_purge_enabled` on, every soft delete (including an agent's) also
300
+ purges documents that have been in the trash longer than `trash_retention_days`. So an
301
+ agent's action can *trigger* a purge. It stays within the model because:
302
+
303
+ - **a human turned the policy on and chose the period**: that period is the review
304
+ window of step 2, decided in advance instead of per document;
305
+ - the sweep purges only documents **older than that period**, never the one being
306
+ deleted and never anything an agent selects;
307
+ - there is still **no agent-callable purge**: the sweep function is not exposed over MCP
308
+ or the Edge Functions, has no CLI verb, and, like every RPC, is callable over the
309
+ Data API only with the service-role key.
310
+
311
+ With the setting off (the default), nothing here changes. Design:
312
+ [`docs/specs/trash-auto-purge.md`](../specs/trash-auto-purge.md).
313
+
298
314
  A `cerefox purge-doc` CLI command, a `cerefox_purge_document` MCP tool, or a
299
315
  `/documents/{id}/purge` HTTP endpoint accessible via the Cerefox token would each break this
300
316
  property. **Do not add them without a governance design that replaces the human-in-the-
@@ -0,0 +1,297 @@
1
+ # The `/api/v1` HTTP API
2
+
3
+ `cerefox web` serves a JSON API at `/api/v1` alongside the web UI. It was built
4
+ as the UI's own backend, and since **v1.11.0** it is also usable by other
5
+ clients: any caller can identify itself, so its reads and writes are attributed
6
+ to it rather than to the web app. Since **v1.12.0** it is authenticated for
7
+ callers that are not on the same machine (see below).
8
+
9
+ This guide covers what you need to call it from something that is not the
10
+ bundled UI. It is not an exhaustive endpoint reference; the routes live in
11
+ `packages/memory/src/web/routes/` and the response shapes are pinned by the zod
12
+ schemas in `_shared/schemas/`.
13
+
14
+ **If you are choosing a surface, read [`access-paths.md`](access-paths.md)
15
+ first.** MCP is the right answer for most agents. This API is the right answer
16
+ when you want plain HTTP, are already running `cerefox web` (or Cerefox Local),
17
+ and do not want an MCP client in the loop.
18
+
19
+ ---
20
+
21
+ ## Security posture: local by default, key for anything else
22
+
23
+ Since **v1.12.0** this surface is authenticated for callers that are not on the
24
+ same machine:
25
+
26
+ > **A request arriving on the loopback interface (`127.0.0.1`) is allowed
27
+ > without a credential. A request arriving on any other interface must present
28
+ > the server's API key** as `Authorization: Bearer <key>`.
29
+
30
+ So the web UI, agents on the same machine and localhost scripts need nothing,
31
+ while a caller on a Docker network or across a widened bind needs a key:
32
+
33
+ ```bash
34
+ cerefox api-key generate # cerefox web
35
+ cerefox-local api-key # Cerefox Local (minted at first boot)
36
+ ```
37
+
38
+ Without a valid key the server answers `401` with a `WWW-Authenticate: Bearer`
39
+ challenge and a `detail` explaining what to do. With no key configured at all,
40
+ the gate is off and the server behaves exactly as it did before v1.12.0, so
41
+ upgrading never breaks a working install.
42
+
43
+ **→ [`securing-local-access.md`](securing-local-access.md) is the full guide**:
44
+ whether you need a key at all, why it is drawn at the network boundary, the
45
+ container and reverse-proxy recipes, rotation, and what this deliberately does
46
+ not protect.
47
+
48
+ Two things to carry into the rest of this document:
49
+
50
+ - **There is no TLS here.** The key authenticates; it does not encrypt. This is
51
+ still not an internet-facing API. If you need Cerefox reachable over a
52
+ network you do not control, use the Edge Functions.
53
+ - **`X-Forwarded-For` is never consulted** to decide where a request came from.
54
+ A same-host reverse proxy therefore makes every request look local; that
55
+ topology sets `CEREFOX_API_REQUIRE_KEY=1`.
56
+
57
+ ## Identifying your client
58
+
59
+ By default every call is recorded as the web app: author `web-ui`, author type
60
+ `user`, access path `webapp`. Supply an identity and it is recorded as you.
61
+
62
+ **This identity is declared, not verified.** It is a label the caller chooses,
63
+ recorded for attribution and record-keeping: so the audit trail says which
64
+ harness wrote a document, so usage analytics can tell your bot's reads from the
65
+ web app's, and so an agent-authored ingest is queued for review (while the
66
+ review workflow is on). It is not a
67
+ credential, it grants nothing, and nothing checks it. See
68
+ [Attribution is not authentication](#attribution-is-not-authentication) below
69
+ before relying on it for anything else.
70
+
71
+ Three optional fields:
72
+
73
+ | Field | Header | Recorded as | Default |
74
+ |---|---|---|---|
75
+ | author | `X-Cerefox-Author` | `cerefox_audit_log.author` on writes | `web-ui` |
76
+ | requestor | `X-Cerefox-Requestor` | `cerefox_usage_log.requestor` on reads and writes | `web-ui` |
77
+ | author type | `X-Cerefox-Author-Type` | `cerefox_audit_log.author_type` | `user` |
78
+
79
+ `author_type` must be `user` or `agent`. Anything else is a `400` naming the
80
+ two valid values.
81
+
82
+ **Headers work on every method.** A `GET` cannot carry a body, and reads are
83
+ half the point of attribution, so headers are the primary mechanism. Endpoints
84
+ that take a JSON body (or a multipart form) also accept the same three as
85
+ fields in it, for clients that find that more natural. If both appear, the
86
+ header wins.
87
+
88
+ ```bash
89
+ # A write, attributed
90
+ curl -X POST http://127.0.0.1:8000/api/v1/ingest \
91
+ -H 'Content-Type: application/json' \
92
+ -H 'X-Cerefox-Author: my-bot' \
93
+ -H 'X-Cerefox-Author-Type: agent' \
94
+ -d '{"title": "Meeting notes", "content": "# Notes\n\n..."}'
95
+
96
+ # A read, attributed
97
+ curl http://127.0.0.1:8000/api/v1/documents/<uuid> \
98
+ -H 'X-Cerefox-Requestor: my-bot'
99
+ ```
100
+
101
+ `author` and `requestor` stand in for each other. They name one actor; the
102
+ split is historical (the MCP tools used `requestor` on reads and `author` on
103
+ writes until v1.13.1, which collapsed them to `author` with `requestor` kept as
104
+ an alias). Supplying either identifies you for both, so a client that only sets
105
+ `X-Cerefox-Author` still has its reads attributed correctly.
106
+
107
+ ### Two consequences worth knowing before you use it
108
+
109
+ **`author_type: agent` marks your documents as pending review.** An
110
+ agent-authored ingest is recorded `pending_review` rather than `approved`,
111
+ exactly as it is over MCP. That equivalence is the point of the feature: the
112
+ same actor is recorded, and treated, identically whichever transport it used.
113
+ If you want your writes approved on arrival, send `user` (or send nothing).
114
+
115
+ Whether anyone *sees* that status is the store's `review_workflow_enabled`
116
+ flag. With the workflow **off** (the default on a fresh v1.13.0+ install) the
117
+ API does not expose review status at all: no row carries a `review_status`
118
+ key, `GET /search?review_status=…` is a `400`, and
119
+ `POST /documents/{id}/review-status` is a `404`. The status is still recorded
120
+ (v1.13.1), so turning the workflow on later shows exactly what the store would
121
+ have had all along. Read the flag from `GET /config/review_workflow_enabled`
122
+ if your client needs to know. Attribution is unaffected either way. See
123
+ [configuration.md → Review Workflow](configuration.md#review-workflow).
124
+
125
+ **Identifying yourself changes the access path.** There is deliberately no way
126
+ to ask for a particular `access_path`: the server derives it. Supply any
127
+ identity field and the operation is logged as `api`; supply none and it is
128
+ logged as `webapp`. The reasoning is that `access_path` is the one field in the
129
+ usage log the server still sets itself, so accepting it from the caller would
130
+ let a client misreport which transport it used. In the Analytics dashboard,
131
+ `api` operations count toward the agent total; `webapp` does not.
132
+
133
+ ### Attribution is not authentication
134
+
135
+ `author`, `requestor` and `author_type` are **declared by the caller and taken
136
+ at face value**. Nothing verifies them, here or anywhere else in Cerefox: MCP
137
+ takes `author` as a client-declared string, and the Edge Functions accept
138
+ whatever `requestor` arrives. Any client can send any name, including a name
139
+ another client uses.
140
+
141
+ They exist for **attribution and record-keeping**: a legible audit trail, usage
142
+ analytics that can tell callers apart, and the review-queue behaviour of
143
+ `author_type: agent` while the review workflow is on. They are not a security measure, they do not establish an
144
+ authenticated identity, and no access decision is made on them. Treat a name in
145
+ the audit log as "what the caller said", never as "who the caller was".
146
+
147
+ The API key added in v1.12.0 (#229) does not change this. It proves a caller is
148
+ *allowed to talk to this server*; it does not prove the caller is *who it says
149
+ it is*. A remote client holding the key can still send any `author` it likes.
150
+ Authentication and attribution stay separate on purpose — conflating them would
151
+ produce a design that does neither well.
152
+
153
+ `require_requestor_identity` and `requestor_identity_format` do **not** apply to
154
+ this surface. They are enforced by the Edge Functions only. See
155
+ [`configuration.md`](configuration.md).
156
+
157
+ ---
158
+
159
+ ## Endpoints
160
+
161
+ **A machine-readable OpenAPI 3.1 document lives at
162
+ [`docs/api/openapi.json`](../api/openapi.json).** If you are generating a client
163
+ or a tool set, read that rather than this table: it carries the request and
164
+ response *shapes*, the path parameters and the error model, which a prose table
165
+ cannot. Point any OpenAPI tool at it.
166
+
167
+ It is **generated** (`bun scripts/gen_openapi.ts`) from three sources that are
168
+ each kept honest on their own: the route registrations, the summaries in the
169
+ table below, and the zod schemas in `_shared/schemas/`. Those schemas are
170
+ asserted against a running server by `api-schema-truth.test.ts`, so the spec
171
+ describes what the API returns rather than what someone believed it returned.
172
+
173
+ Coverage is stated in the document itself under `x-cerefox-coverage`. Endpoints
174
+ with no modelled shape are listed there and get no response schema rather than
175
+ an invented one — a spec that guesses is worse than one that admits a gap.
176
+
177
+ Base URL is wherever `cerefox web` is listening (`http://127.0.0.1:8000` by
178
+ default; Cerefox Local picks its own port and `cerefox-local status` prints it).
179
+
180
+ | Method + path | Purpose |
181
+ |---|---|
182
+ | `GET /version` | Server version and environment label. |
183
+ | `GET /schema-version` | Deployed schema version. |
184
+ | `GET /openapi.json` | This API's OpenAPI 3.1 description, served by the version you are talking to. Every `/api/v1` response points at it with `Link: </api/v1/openapi.json>; rel="service-desc"` (RFC 8631). |
185
+ | `GET /search?q=…` | Hybrid search (FTS + semantic). `&review_status=approved\|pending_review` filters server-side; `400` while the review workflow is off. |
186
+ | `GET /dashboard`, `GET /dashboard/recent-docs` | Dashboard aggregates. |
187
+ | `GET /documents/{id}` | Full document, with metadata, projects and versions. |
188
+ | `GET /documents/{id}/chunks` | The document's chunks. |
189
+ | `GET /documents/{id}/versions` | Version history. |
190
+ | `POST /documents/{id}/versions/{version_id}/archive` | Archive or unarchive one stored version (body `{ "archived": true\|false }`). Archiving strips a version's search artifacts; its content is kept as the safety copy. |
191
+ | `GET /documents/{id}/download` | Raw markdown. |
192
+ | `GET /documents/trash` | Soft-deleted documents, newest first, `limit` ≤ 500. The exact total is in the `X-Total-Count` response header (v1.14.1). `deleted_before=<ISO timestamp>` restricts both to documents trashed before then. |
193
+ | `POST /documents/metadata-search` | Query by metadata / project / time, no text query. |
194
+ | `GET /metadata-keys` | Metadata keys with counts and example values. |
195
+ | `GET /resolve-link`, `GET /check-filename` | Link and title resolution helpers. |
196
+ | `POST /ingest` | Create or update from JSON (`title`, `content`, …). Unknown fields are a `400`. |
197
+ | `POST /ingest/file` | Create from a multipart file upload. |
198
+ | `POST /documents/{id}/upload` | Replace a document's content from a file. |
199
+ | `POST /documents/{id}/edit` | Update title, content, metadata, projects. Unknown fields are a `400`. |
200
+ | `DELETE /documents/{id}` | Soft-delete (to trash). Returns `auto_purged`: documents this delete purged because they had outlived `trash_retention_days` (only while trash auto-purge is on). |
201
+ | `POST /documents/{id}/restore` | Restore from trash. |
202
+ | `DELETE /documents/{id}/purge` | Permanent delete. Irreversible. |
203
+ | `POST /documents/{id}/review-status` | Approve or re-queue for review. `404` while the review workflow is off. |
204
+ | `GET /projects`, `POST /projects`, `PUT /projects/{id}`, `DELETE /projects/{id}` | Project CRUD. |
205
+ | `GET /projects/{id}/documents` | A project's documents. |
206
+ | `GET /config`, `GET /config/{key}`, `PUT /config/{key}` | Runtime config. |
207
+ | `GET /audit-log` | Audit trail, filterable. |
208
+ | `GET /preferences`, `PUT /preferences` | Web UI preferences (currently the colour scheme), stored in a file in the user-state dir, not the database. Registers even when Supabase is not configured. |
209
+ | `GET /docs`, `GET /docs/{path}` | The guides bundled with this server: a listing, and one document's markdown. Serves the shipped copies, so it answers the same content regardless of what is in the knowledge base. |
210
+ | `GET /usage-log`, `GET /usage-log/summary`, `GET /usage-log/export.csv` | Usage queries. |
211
+
212
+ Paths are shown without the `/api/v1` prefix for width; every one carries it.
213
+ The writes and the two reads that log usage (`/search`, `/documents/{id}`)
214
+ honour the identity headers. `/version` needs nothing.
215
+
216
+ ### Every mutation needs a concurrency token
217
+
218
+ Cerefox uses optimistic locking, and this API is no exception. Read the
219
+ document first: `GET /documents/{id}` returns its `content_hash`. Then send
220
+ that hash back with the write. A stale hash is a `409` carrying `current_hash`,
221
+ so you can re-read without a second round trip. Sending no hash (and no
222
+ `last_write_wins`) is a `400` with `CEREFOX_TOKEN_REQUIRED`.
223
+
224
+ | Write | Where the hash goes | `last_write_wins` |
225
+ |---|---|---|
226
+ | `POST /ingest` with `document_id`, or with `update_if_exists` matching a document | `expected_content_hash` in the body | Accepted |
227
+ | `POST /documents/{id}/edit` | `expected_content_hash` in the body. Checked on every save, including a metadata-only one | Accepted |
228
+ | `POST /documents/{id}/upload`, `POST /ingest/file` with `update_existing` | `expected_content_hash` form field | `last_write_wins=true` form field |
229
+ | `DELETE /documents/{id}` | `X-Cerefox-Expected-Content-Hash` header, or `expected_content_hash` query parameter | Not accepted |
230
+ | `POST /documents/{id}/restore`, `DELETE /documents/{id}/purge` | No hash | Not applicable |
231
+
232
+ `last_write_wins` skips the check. Use it only when an external source of truth
233
+ (a file you are syncing, say) makes a conflict meaningless; there is no implicit
234
+ default. Creating a new document needs no hash.
235
+
236
+ **Check the status code, not just the body.** Every refusal is a real HTTP
237
+ status: `400` for a malformed or incomplete request (including a body field the
238
+ route does not know, which is refused rather than ignored), `404` for a missing
239
+ document or version, `409` for a concurrency conflict or a trashed document,
240
+ `422` for content that links to document ids that do not exist, and `503` when
241
+ the embedder is unavailable. The reason is in the body's `detail` (and, on the
242
+ ingest and edit routes, also in `error`, which they have always used). Until
243
+ v1.15.2 a delete with a stale hash answered `500`, and `POST /ingest` answered
244
+ `200` with `success: false` for a missing title or empty content; both are
245
+ fixed.
246
+
247
+ **Delete follows a read too.** A caller that identifies itself must send the
248
+ hash on `DELETE /documents/{id}`, exactly as `cerefox_delete_document` requires
249
+ it over MCP. A caller that sends no identity is the bundled web UI, which
250
+ confirms with the human in a dialog instead, and it keeps working unchanged.
251
+
252
+ The full contract of every endpoint (parameters, headers, bodies, error codes)
253
+ is in [`docs/api/openapi.json`](../api/openapi.json), generated from the route
254
+ handlers themselves. A running server serves its own copy at
255
+ `GET /api/v1/openapi.json` (since v1.15.3), so a client always gets the
256
+ contract of the version it is actually talking to. It does not need to know
257
+ that path: every `/api/v1` response carries
258
+ `Link: </api/v1/openapi.json>; rel="service-desc"`, the standard pointer to a
259
+ machine-readable API description (RFC 8631).
260
+
261
+ Purge (`DELETE /documents/{id}/purge`) is irreversible and takes no token. It is
262
+ reachable by anything that can reach the port, which is another reason the
263
+ warning at the top of this guide is not boilerplate.
264
+
265
+ The response is `{ "success": true, "purged": <bool> }` (v1.14.0): `purged` is
266
+ `false` when the document was no longer in the trash when the call landed (a
267
+ restore raced it) and is still live; the RPC is a silent no-op in that case.
268
+
269
+ **There is no bulk purge.** The web UI's "Empty trash" (v1.14.0) is a loop in
270
+ the browser over this per-document endpoint, one call and one audit entry per
271
+ document, with a confirmation and a Stop button in front of it. It purges only
272
+ the documents listed when the count was confirmed; anything trashed afterwards
273
+ stays in the trash. A single
274
+ request that emptied the trash would be a footgun on a surface anything with
275
+ the key can reach, so it was not added and should not be.
276
+
277
+ ---
278
+
279
+ ## Choosing between this and MCP
280
+
281
+ Both surfaces run the same code underneath and write the same rows. The
282
+ practical differences:
283
+
284
+ | | `/api/v1` | MCP |
285
+ |---|---|---|
286
+ | Transport | Plain HTTP | stdio or Streamable HTTP |
287
+ | Client needs | An HTTP client | An MCP client |
288
+ | Identity | Optional, defaults to `web-ui` | Per-call `author` |
289
+ | Section-level edits | None: `POST /documents/{id}/edit` replaces the whole content | `cerefox_insert`, `cerefox_edit` |
290
+ | Guidance for agents | This guide | `cerefox_get_help()`, in-band |
291
+ | Authentication | None on loopback; API key otherwise | None locally; token or OAuth remotely |
292
+
293
+ If an agent is doing the calling, MCP is usually better: the tools carry their
294
+ own documentation, and `cerefox_get_help()` keeps the conventions in front of
295
+ the model. Reach for the API when the caller is a program you are writing
296
+ yourself, or when you want one transport for a harness that already speaks
297
+ HTTP.
@@ -270,6 +270,53 @@ cerefox document get <document-id> --version-id <version-id>
270
270
 
271
271
  ---
272
272
 
273
+ ## Trash auto-purge
274
+
275
+ Deleting a document moves it to the trash, where it stays until someone restores or
276
+ purges it. On a store where agents create and delete a lot, the trash grows without
277
+ bound. Since v1.16.0 you can have Cerefox purge old trash automatically. It is
278
+ **off by default**.
279
+
280
+ | Setting | Default | What it does |
281
+ |---|---|---|
282
+ | `trash_auto_purge_enabled` | `false` | Turn automatic purging on or off. |
283
+ | `trash_retention_days` | `60` | How long a document stays in the trash before it may be purged: your window to restore it. Whole days, 1 to 3650. |
284
+
285
+ Set them from **Settings** (Retention) or the CLI:
286
+
287
+ ```bash
288
+ cerefox config set trash_retention_days 60
289
+ cerefox config set trash_auto_purge_enabled true
290
+ ```
291
+
292
+ **When it runs.** Cerefox has no scheduler. The trash only grows when a document is
293
+ deleted, so that is when it is swept: each delete also purges documents that have been
294
+ in the trash longer than `trash_retention_days`, up to 100 per delete, oldest first.
295
+ A document therefore becomes *eligible* after the retention period and is removed by
296
+ **the next delete after that**. If nothing is deleted, expired documents stay, and
297
+ nothing new arrives in the trash either.
298
+
299
+ **What you can rely on.**
300
+
301
+ - Only documents already older than the retention period are purged, never the one
302
+ being deleted.
303
+ - A failure while sweeping never fails the delete: the delete completes, and the sweep
304
+ is retried on the next one.
305
+ - Every purge is in the audit log (author `trash-retention`), naming the retention in
306
+ force and the day the document was trashed.
307
+ - A delete reports how many documents it purged: `auto_purged` in the
308
+ `/api/v1` response, a line in the CLI and MCP output.
309
+ - `cerefox doctor` shows whether it is on and with which period.
310
+ - Settings asks for confirmation and tells you how many documents already in the trash
311
+ the next delete would purge. Shortening the period while it is on gets the same
312
+ confirmation.
313
+
314
+ Purged documents cannot be restored. Turning this on for a store with a large, old
315
+ trash purges it over the next few deletes; check the Trash page first if anything
316
+ there matters.
317
+
318
+ ---
319
+
273
320
  ## Storage & Backup
274
321
 
275
322
  | Variable | Default | Description |
@@ -0,0 +1,118 @@
1
+ # Document links: capabilities and guardrails
2
+
3
+ Cerefox documents can link to each other with ordinary markdown links. This
4
+ guide covers how linking works, and the deterministic guardrails (v1.7.0+)
5
+ that protect links from the most common way they break: an AI agent
6
+ corrupting a long document id while rewriting content.
7
+
8
+ ## How linking works
9
+
10
+ Write a normal markdown link whose target is the document's UUID:
11
+
12
+ ```markdown
13
+ See the [Opportunity Index](c937b70f-77af-43d3-b9bc-9f31e0d2041d) for details.
14
+ ```
15
+
16
+ The Cerefox web UI resolves these at click time — the stored markdown is
17
+ untouched, and the link keeps working if the target is renamed. Every search
18
+ result and every write response includes the document id, so an id is always
19
+ one lookup away.
20
+
21
+ Four link forms are accepted; one is recommended:
22
+
23
+ | Form | Example target | Use |
24
+ |---|---|---|
25
+ | **Document UUID** | `c937b70f-…` | **Always, for cross-references.** Stable, unambiguous, encoding-safe, validated (below). |
26
+ | Repo-relative path | `docs/guides/quickstart.md` | Repo-ingested files only (their markdown naturally uses paths). |
27
+ | Basename | `quickstart.md` | Best-effort fallback for the same. |
28
+ | `[[Wikilink]]` | `[[Future Doc Name]]` | A deliberate **dangling** reference — "this document should exist someday." Never validated. |
29
+
30
+ Agent-specific writing rules live in `AGENT_GUIDE.md` → "Writing linkable
31
+ content".
32
+
33
+ ## Why links need a guardrail: LLMs and long ids
34
+
35
+ This is worth understanding, because it explains the design and it is not a
36
+ bug in any particular AI model — it is structural.
37
+
38
+ A language model editing a document does not copy text the way a program
39
+ does. It **regenerates** the text token by token from an internal
40
+ representation. Normal prose survives this well because language is
41
+ redundant — if a word drifts slightly, the surrounding context constrains it
42
+ back. A UUID has **zero redundancy**: 32 hex characters, each independent,
43
+ carrying no meaning that could catch a mistake. `508e4c21` regenerated as
44
+ `502041d8` looks equally plausible to the model, and to anyone reading past
45
+ it.
46
+
47
+ Now compound that. A document with a table of 25–30 links makes an agent
48
+ regenerate 25–30 UUIDs *every time it rewrites that table*. Even an
49
+ excellent per-character accuracy, multiplied across hundreds of characters
50
+ of pure entropy, several times per session, makes occasional corruption
51
+ **expected, not unlucky** — and observed repeatedly in real use. Each
52
+ corrupted id silently becomes a
53
+ dead link that read-back review may or may not catch.
54
+
55
+ Two defenses exist, and they compose:
56
+
57
+ 1. **Reduce how much gets regenerated**: the partial-edit tools
58
+ (`cerefox_insert`, `cerefox_edit`) change only the targeted section, so
59
+ untouched links are never retyped and literally cannot corrupt. Give
60
+ link-heavy tables their own heading so appends never rewrite existing
61
+ rows.
62
+ 2. **Catch what still gets retyped**: the write-time validation below.
63
+
64
+ ## The write-time guardrail (v1.7.0+)
65
+
66
+ Every write — create, full update, or partial edit, on every surface (MCP,
67
+ CLI, web, GPT Actions) — validates the `[Text](uuid)` links in the content
68
+ against the store, inside the same database transaction as the write:
69
+
70
+ - **A link to a document id that does not exist rejects the whole write**,
71
+ and the error lists every unresolvable id. Nothing is stored; the
72
+ corruption never lands. The check is deterministic and costs ~1–2ms.
73
+ - **The agent can self-correct in the same turn**: the error says to re-read
74
+ the source each link was copied from, fix the id(s), and resend — and
75
+ explicitly not to retry unchanged.
76
+ - **Examples escape via code formatting.** A link inside backticks or a
77
+ fenced code block is an example, not a link, and is not validated — which
78
+ is also just correct markdown, since a bare link-syntax example would
79
+ render as a real link. There is deliberately **no bypass flag**.
80
+ - **A trashed target still resolves** (the id denotes a document; restoring
81
+ decides its fate). **`[[Wikilinks]]` are never validated** — that is the
82
+ sanctioned form for a forward reference.
83
+
84
+ ### Updates validate only what the write introduces
85
+
86
+ On an update, only **newly-introduced** link ids are validated. A dead link
87
+ the document *already carried* — typically because its target was purged
88
+ after the link was written — does not block an unrelated edit or a file
89
+ re-sync. Without this, one purge could make every document that ever linked
90
+ the purged target permanently unwritable through automated paths.
91
+
92
+ The corollary: a link that was mangled *before* v1.7.0, or whose target was
93
+ purged later, sits undetected in the content. That is what the sweep is for.
94
+
95
+ ## The dead-link sweep (v1.7.1+)
96
+
97
+ ```bash
98
+ cerefox document dead-links # human-readable report
99
+ cerefox document dead-links --json # machine-readable
100
+ ```
101
+
102
+ One server-side pass over the whole knowledge base, reporting every
103
+ `[Text](uuid)` link whose target no longer exists — with the linking
104
+ document and an occurrence count. Same scanning rules as the write guard
105
+ (the two share one implementation, so they cannot disagree). Trashed
106
+ *linker* documents are excluded until restored. Run it after a purge, or
107
+ periodically; it is a full content scan, so it is deliberately on demand
108
+ rather than part of `cerefox doctor`.
109
+
110
+ Fix each hit by editing the linking document: correct the id, remove the
111
+ link, or backtick it as an example.
112
+
113
+ ## Design record
114
+
115
+ The full rationale — including the rejected bypass-flag design, why fence
116
+ handling splits on lines instead of pairing with a regex, and the
117
+ probability analysis of a mangled id colliding with a real document — is in
118
+ [`docs/specs/link-integrity-design.md`](../specs/link-integrity-design.md).