@cerefox/memory 1.3.0 → 1.5.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.
@@ -62,25 +62,44 @@ export type EditOperation =
62
62
  anchor_heading: string;
63
63
  scope?: "body_only" | "heading_and_body";
64
64
  section_part?: SectionPart;
65
+ }
66
+ | {
67
+ op: "rename_section";
68
+ anchor_heading: string;
69
+ new_heading: string;
65
70
  };
66
71
 
67
72
  /** Echo of one applied operation, for audit labels and response text. */
68
73
  export interface AppliedOperation {
69
- op: "insert" | "replace_section" | "delete_section";
74
+ op: "insert" | "replace_section" | "delete_section" | "rename_section";
70
75
  /** Resolved anchor path (or "(document)" for end_of_document). */
71
76
  path: string;
72
77
  /** Human summary: position / scope / section_part actually used. */
73
78
  detail: string;
79
+ /**
80
+ * For destructive ops: did the targeted section run to the END of the
81
+ * document it was applied to? (#196 — the last section owns anything
82
+ * appended after it, which is the loss that surprises people.)
83
+ *
84
+ * Decided HERE, at resolution time, rather than by re-parsing the pre-batch
85
+ * document later. In a batch, each operation resolves against the content
86
+ * left by the previous one, so a `rename_section` earlier in the same call
87
+ * means a later op's `path` names a heading the pre-batch outline has never
88
+ * heard of — and a path lookup against that outline silently finds nothing.
89
+ * That is precisely the batch shape `rename_section` was added to enable.
90
+ */
91
+ reachedEnd?: boolean;
74
92
  }
75
93
 
76
94
  /** Anchor matched nothing. The write must never fall back to appending. */
77
95
  export class AnchorNotFoundError extends Error {
78
- constructor(anchor: string, outline: OutlineNode[]) {
96
+ /** `reads` — see AmbiguousPositionError: a read never attempted a write. */
97
+ constructor(anchor: string, outline: OutlineNode[], reads = false) {
79
98
  const known = outline.length
80
99
  ? ` Known headings:\n${outline.map((n) => ` ${n.path}`).join("\n")}`
81
100
  : " The document has no headings.";
82
101
  super(
83
- `Anchor not found: "${anchor}". No write was performed.${known}\n` +
102
+ `Anchor not found: "${anchor}".${reads ? "" : " No write was performed."}${known}\n` +
84
103
  `Anchors match a heading line exactly ("## Title") or a parent path ` +
85
104
  `("## Parent > ### Child").`,
86
105
  );
@@ -91,10 +110,11 @@ export class AnchorNotFoundError extends Error {
91
110
  /** Anchor matched more than one section. Candidates resolve the retry. */
92
111
  export class AmbiguousAnchorError extends Error {
93
112
  readonly candidates: string[];
94
- constructor(anchor: string, candidates: string[]) {
113
+ /** `reads` — see AmbiguousPositionError: a read never attempted a write. */
114
+ constructor(anchor: string, candidates: string[], reads = false) {
95
115
  super(
96
116
  `Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` +
97
- `No write was performed. Disambiguate by passing one of these paths as anchor_heading:\n` +
117
+ `${reads ? "" : "No write was performed. "}Disambiguate by passing one of these paths as anchor_heading:\n` +
98
118
  candidates.map((c) => ` ${c}`).join("\n"),
99
119
  );
100
120
  this.name = "AmbiguousAnchorError";
@@ -108,7 +128,13 @@ export class AmbiguousAnchorError extends Error {
108
128
  */
109
129
  export class AmbiguousPositionError extends Error {
110
130
  readonly candidates: { section_part: SectionPart; description: string }[];
111
- constructor(node: OutlineNode, firstChildHeading: string, opName: string) {
131
+ /**
132
+ * `reads` marks a caller that only inspects content (#198's section read).
133
+ * The reassurance "No write was performed" is meant to stop an agent
134
+ * retrying a half-applied batch — on a read it is noise at best, and at
135
+ * worst it implies a write was attempted when none ever could be.
136
+ */
137
+ constructor(node: OutlineNode, firstChildHeading: string, opName: string, reads = false) {
112
138
  const candidates = [
113
139
  {
114
140
  section_part: "own_body" as const,
@@ -126,7 +152,7 @@ export class AmbiguousPositionError extends Error {
126
152
  super(
127
153
  `Ambiguous position: "${node.path}" has child sections, so "the end of ` +
128
154
  `the section" could mean two different places and ${opName} will not guess. ` +
129
- `No write was performed. Pass section_part to choose:\n` +
155
+ `${reads ? "" : "No write was performed. "}Pass section_part to choose:\n` +
130
156
  candidates.map((c) => ` section_part: "${c.section_part}" — ${c.description}`).join("\n"),
131
157
  );
132
158
  this.name = "AmbiguousPositionError";
@@ -134,6 +160,55 @@ export class AmbiguousPositionError extends Error {
134
160
  }
135
161
  }
136
162
 
163
+ /**
164
+ * A rename asked to change the heading's LEVEL, not just its text.
165
+ *
166
+ * `## Q3 plan` → `### Q3 plan` re-parents everything under it: the section
167
+ * stops being a sibling of its neighbours and becomes a child of whichever
168
+ * section precedes it. That is a restructure with a different blast radius
169
+ * from a rename, and doing it silently under the name "rename" is exactly the
170
+ * kind of surprise this contract refuses (#197).
171
+ */
172
+ export class HeadingLevelChangeError extends Error {
173
+ constructor(fromHeading: string, toHeading: string) {
174
+ const from = (fromHeading.match(/^#+/) ?? [""])[0].length;
175
+ const to = (toHeading.match(/^#+/) ?? [""])[0].length;
176
+ super(
177
+ `rename_section changes heading TEXT, not depth: "${fromHeading}" is level ` +
178
+ `${from} and "${toHeading}" is level ${to}. No write was performed. ` +
179
+ `Changing the level would re-parent every section nested under it. ` +
180
+ `Pass a level-${from} heading (${"#".repeat(from)} ...), or restructure ` +
181
+ `explicitly with delete_section + insert if that is what you meant.`,
182
+ );
183
+ this.name = "HeadingLevelChangeError";
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The supplied text begins with the very heading it is anchored to.
189
+ *
190
+ * `insert` splices text verbatim and `replace_section` preserves the heading,
191
+ * so a caller who includes the heading in their content gets two of them — and
192
+ * the tools said nothing. Two separate incidents in one agent's log came from
193
+ * this, the second while trying to repair the first, which is the shape that
194
+ * makes a silent trap expensive: the fix looks like more of the same call.
195
+ *
196
+ * There is no legitimate reading of "this section's body starts with this
197
+ * section's own heading", so this refuses rather than warns.
198
+ */
199
+ export class DuplicateHeadingError extends Error {
200
+ constructor(heading: string, opName: string) {
201
+ super(
202
+ `The text you passed to ${opName} starts with the anchor heading itself ` +
203
+ `(${JSON.stringify(heading)}). No write was performed. The heading is kept ` +
204
+ `automatically — ${opName === "replace_section" ? "replace_section preserves it" : "insert places your text inside the section"}, ` +
205
+ `so including it would produce two. Send only the new content, without that ` +
206
+ `heading line. A DEEPER sub-heading inside your text is fine.`,
207
+ );
208
+ this.name = "DuplicateHeadingError";
209
+ }
210
+ }
211
+
137
212
  /** Structural validation failure of an operations array (before any parsing). */
138
213
  export class InvalidOperationError extends Error {
139
214
  constructor(index: number, message: string) {
@@ -240,7 +315,11 @@ function canonicalHeading(text: string): string {
240
315
  return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
241
316
  }
242
317
 
243
- export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): OutlineNode {
318
+ export function resolveAnchor(
319
+ outline: OutlineNode[],
320
+ anchorHeading: string,
321
+ reads = false,
322
+ ): OutlineNode {
244
323
  const anchor = canonicalHeading(anchorHeading);
245
324
 
246
325
  // Try the LITERAL heading first, always — including when the anchor contains
@@ -253,7 +332,7 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
253
332
  const byHeading = outline.filter((n) => n.heading === anchor);
254
333
  if (byHeading.length === 1) return byHeading[0];
255
334
  if (byHeading.length > 1) {
256
- throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
335
+ throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path), reads);
257
336
  }
258
337
 
259
338
  // No heading matched literally: interpret it as a parent path.
@@ -262,11 +341,79 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
262
341
  const byPath = outline.filter((n) => n.path === normalizedPath);
263
342
  if (byPath.length === 1) return byPath[0];
264
343
  if (byPath.length > 1) {
265
- throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
344
+ throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path), reads);
266
345
  }
267
346
  }
268
347
 
269
- throw new AnchorNotFoundError(anchor, outline);
348
+ throw new AnchorNotFoundError(anchor, outline, reads);
349
+ }
350
+
351
+ /**
352
+ * The text a `replace_section` would overwrite — resolved through the SAME
353
+ * functions the write uses (#198).
354
+ *
355
+ * v1.3.0 shipped `replace_section` with no way to see what it was about to
356
+ * destroy: outline mode reports a section's *size*, never its *text*, so the
357
+ * only safe preparation was a full `get_document` — the cost partial edits
358
+ * exist to remove. `cerefox_insert` is guarded structurally (it cannot remove
359
+ * anything); the destructive operation was guarded only by
360
+ * `expected_content_hash`, which protects against a *concurrent* writer, not
361
+ * against a writer who does not know what it is deleting.
362
+ *
363
+ * The binding requirement is that `text` is EXACTLY the extent
364
+ * `replace_section` targets under the same `section_part`. If a read could
365
+ * differ from the write it feeds, the feature would be worse than its absence:
366
+ * absence at least announces itself. That is why this shares `resolveAnchor`
367
+ * and `resolveSectionEnd` rather than reproducing their rules — including the
368
+ * refusal on a section with children, which is the case most likely to diverge.
369
+ *
370
+ * `heading` is returned separately because it is context, not content:
371
+ * `replace_section` keeps the heading, so it is not part of what would be
372
+ * overwritten.
373
+ */
374
+ export function extractSection(
375
+ content: string,
376
+ anchorHeading: string,
377
+ sectionPart?: SectionPart,
378
+ ): {
379
+ heading: string;
380
+ path: string;
381
+ level: number;
382
+ text: string;
383
+ chars: number;
384
+ section_part: SectionPart | null;
385
+ } {
386
+ const outline = parseOutline(content);
387
+ const node = resolveAnchor(outline, anchorHeading, true);
388
+ // Same op label the write would raise under, so an ambiguity refusal reads
389
+ // the same whether the caller was reading or replacing.
390
+ const to = resolveSectionEnd(content, outline, node, sectionPart, "the section read", true);
391
+ const text = content.slice(node.bodyStart, to);
392
+ return {
393
+ heading: node.heading,
394
+ path: node.path,
395
+ level: node.level,
396
+ text,
397
+ chars: text.length,
398
+ section_part: sectionPart ?? null,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * First non-blank line of a block, trimmed — what a caller "starts with".
404
+ */
405
+ function firstMeaningfulLine(text: string): string {
406
+ for (const line of text.split("\n")) {
407
+ if (line.trim() !== "") return canonicalHeading(line);
408
+ }
409
+ return "";
410
+ }
411
+
412
+ /** Refuse text that repeats the heading it is being placed under. */
413
+ function assertNoDuplicateHeading(text: string, node: OutlineNode, opName: string): void {
414
+ if (firstMeaningfulLine(text) === node.heading) {
415
+ throw new DuplicateHeadingError(node.heading, opName);
416
+ }
270
417
  }
271
418
 
272
419
  function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
@@ -288,6 +435,7 @@ function resolveSectionEnd(
288
435
  node: OutlineNode,
289
436
  sectionPart: SectionPart | undefined,
290
437
  opName: string,
438
+ reads = false,
291
439
  ): number {
292
440
  const child = firstChild(outline, node);
293
441
  if (!child) return node.subtreeEnd; // leaf: unambiguous
@@ -315,7 +463,7 @@ function resolveSectionEnd(
315
463
  //
316
464
  // So: no exemption. `hasOwnBody` no longer gates anything, because the
317
465
  // presence of children is what makes "the end of this section" ambiguous.
318
- throw new AmbiguousPositionError(node, child.heading, opName);
466
+ throw new AmbiguousPositionError(node, child.heading, opName, reads);
319
467
  }
320
468
 
321
469
  /**
@@ -362,9 +510,15 @@ function applyOne(
362
510
  at = node.start;
363
511
  detail = "insert before_heading";
364
512
  } else if (position === "after_heading") {
513
+ // Lands INSIDE the section, immediately below its heading — the same
514
+ // duplication trap as end_of_section. `before_heading` is deliberately
515
+ // NOT guarded: text placed before a heading becomes a new sibling
516
+ // section, and one that repeats the name is a legitimate way to split.
517
+ assertNoDuplicateHeading(text, node, "insert");
365
518
  at = node.bodyStart;
366
519
  detail = "insert after_heading";
367
520
  } else {
521
+ assertNoDuplicateHeading(text, node, "insert");
368
522
  at = resolveSectionEnd(content, outline, node, operation.section_part, "end_of_section insert");
369
523
  detail =
370
524
  `insert at end_of_section` +
@@ -378,6 +532,7 @@ function applyOne(
378
532
 
379
533
  if (operation.op === "replace_section") {
380
534
  const node = resolveAnchor(outline, operation.anchor_heading);
535
+ assertNoDuplicateHeading(operation.text, node, "replace_section");
381
536
  const to = resolveSectionEnd(content, outline, node, operation.section_part, "replace_section");
382
537
  return {
383
538
  content: spliceBlock(content, node.bodyStart, to, operation.text),
@@ -386,6 +541,34 @@ function applyOne(
386
541
  path: node.path,
387
542
  detail:
388
543
  "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
544
+ reachedEnd: to >= content.trimEnd().length,
545
+ },
546
+ };
547
+ }
548
+
549
+ if (operation.op === "rename_section") {
550
+ const node = resolveAnchor(outline, operation.anchor_heading);
551
+ const next = canonicalHeading(operation.new_heading);
552
+ const m = next.match(ATX_HEADING);
553
+ if (!m) {
554
+ throw new InvalidOperationError(
555
+ 0,
556
+ `new_heading must be a markdown heading line like "## Title", got ${JSON.stringify(operation.new_heading)}`,
557
+ );
558
+ }
559
+ if (m[1].length !== node.level) throw new HeadingLevelChangeError(node.heading, next);
560
+
561
+ // Replace the heading LINE only. Not spliceBlock: that normalises
562
+ // surrounding blank lines, which is right for a block of body text and
563
+ // wrong for a single line whose neighbours are the section's own spacing.
564
+ const hadNewline = content[node.bodyStart - 1] === "\n";
565
+ const replacement = next + (hadNewline ? "\n" : "");
566
+ return {
567
+ content: content.slice(0, node.start) + replacement + content.slice(node.bodyStart),
568
+ applied: {
569
+ op: "rename_section",
570
+ path: node.path,
571
+ detail: `rename_section to ${next}`,
389
572
  },
390
573
  };
391
574
  }
@@ -402,6 +585,7 @@ function applyOne(
402
585
  path: node.path,
403
586
  detail:
404
587
  `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
588
+ reachedEnd: to >= content.trimEnd().length,
405
589
  },
406
590
  };
407
591
  }
@@ -453,8 +637,33 @@ export function validateOperations(operations: unknown): EditOperation[] {
453
637
  if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
454
638
  throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
455
639
  }
640
+ } else if (op === "rename_section") {
641
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
642
+ throw new InvalidOperationError(i, "rename_section requires anchor_heading");
643
+ }
644
+ if (typeof o.new_heading !== "string" || o.new_heading.trim() === "") {
645
+ throw new InvalidOperationError(i, "rename_section requires non-empty new_heading");
646
+ }
647
+ // A rename touches the heading line and nothing else, so a caller that
648
+ // also passed body text has misunderstood which operation they want.
649
+ // Silently ignoring it would lose the text without saying so.
650
+ if (o.text !== undefined) {
651
+ throw new InvalidOperationError(
652
+ i,
653
+ "rename_section changes only the heading and takes no text — use replace_section for the body, or both operations in one call",
654
+ );
655
+ }
656
+ if (o.section_part !== undefined) {
657
+ throw new InvalidOperationError(
658
+ i,
659
+ "rename_section takes no section_part: it replaces the heading line, so no extent is involved",
660
+ );
661
+ }
456
662
  } else {
457
- throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section");
663
+ throw new InvalidOperationError(
664
+ i,
665
+ "op must be insert | replace_section | delete_section | rename_section",
666
+ );
458
667
  }
459
668
  if (
460
669
  o.section_part !== undefined &&
@@ -0,0 +1,36 @@
1
+ -- 0021_rename_section_audit_op.sql — audit the rename operation (iteration 35, #197).
2
+ --
3
+ -- `cerefox_audit_log.operation` is CHECK-constrained, so a new partial-edit
4
+ -- operation cannot be recorded until the constraint knows about it. Migration
5
+ -- 0019 widened it for insert / replace-section / delete-section; this adds
6
+ -- rename-section.
7
+ --
8
+ -- Why the operation exists: there was no way to change a heading's own text.
9
+ -- `replace_section` preserves the heading by design, and delete + re-insert
10
+ -- sacrifices the section's body and position to fix the heading — an agent hit
11
+ -- this on real work (a heading whose date had gone stale), judged that trade
12
+ -- wrong, and left the document stale rather than risk the body. Renaming is
13
+ -- therefore its own operation, and one that structurally cannot touch a body.
14
+ --
15
+ -- Schema version 0.11.0 → 0.11.1. Additive: the constraint only widens, so an
16
+ -- older client against this database is unaffected, and this database against
17
+ -- an older client simply never sees the new value.
18
+
19
+ ALTER TABLE cerefox_audit_log
20
+ DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
21
+
22
+ ALTER TABLE cerefox_audit_log
23
+ ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
24
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
25
+ 'status-change', 'archive', 'unarchive', 'restore',
26
+ 'relation-set', 'relation-delete',
27
+ 'insert', 'replace-section', 'delete-section',
28
+ 'rename-section')
29
+ );
30
+
31
+ DO $$
32
+ BEGIN
33
+ RAISE NOTICE
34
+ 'Migration 0021: audit log accepts rename-section (iteration 35, #197). '
35
+ 'Schema version 0.11.1 — re-apply rpcs.sql on this deploy.';
36
+ END $$;
@@ -0,0 +1,38 @@
1
+ -- 0022_rls_on_document_relations.sql — close the one-table RLS gap (iteration 36).
2
+ --
3
+ -- `cerefox_document_relations` was added in iteration 29 and never added to
4
+ -- schema.sql's RLS block, so it alone among the ten tables had row-level
5
+ -- security disabled. Cerefox's model is "RLS ON with NO policies" — the
6
+ -- service-role key bypasses RLS and everything else is denied — so a table
7
+ -- without RLS is reachable by any role holding a table grant.
8
+ --
9
+ -- On projects created before Supabase stopped granting `anon` blanket
10
+ -- privileges on `public` (the maintainer's production project is one), `anon`
11
+ -- holds SELECT/INSERT/UPDATE/DELETE here. The anon / publishable key is
12
+ -- designed to be public, so that means world read AND write on this table.
13
+ -- Supabase's advisor flagged it as `rls_disabled_in_public` on 2026-08-09.
14
+ --
15
+ -- Newer projects grant `anon` nothing, so they were never exposed — which is
16
+ -- why the maintainer's staging project showed no privileges while production
17
+ -- showed all four. Both get RLS regardless: relying on the absence of a grant
18
+ -- is not the same as denying access.
19
+ --
20
+ -- Impact of the gap: the relations feature is opt-in (`relations_enabled`,
21
+ -- default false), so the table is empty on a default install and no document
22
+ -- content was ever reachable through it. Content, chunks, versions, audit log
23
+ -- and config were correctly protected throughout.
24
+ --
25
+ -- Idempotent, and safe on a table that already has RLS.
26
+
27
+ ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
28
+
29
+ -- Defence in depth: revoke the legacy blanket grants so the table is denied by
30
+ -- privilege as well as by RLS. Harmless where the grants were never made.
31
+ REVOKE ALL ON TABLE cerefox_document_relations FROM anon;
32
+
33
+ DO $$
34
+ BEGIN
35
+ RAISE NOTICE
36
+ 'Migration 0022: RLS enabled on cerefox_document_relations and anon '
37
+ 'grants revoked (Supabase rls_disabled_in_public). Schema version 0.11.2.';
38
+ END $$;
@@ -2425,10 +2425,13 @@ SET search_path = public, pg_catalog
2425
2425
  AS $$
2426
2426
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2427
2427
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2428
+ -- 0.11.2 (iteration 36): RLS enabled on cerefox_document_relations,
2429
+ -- which iteration 29 left off the list (Supabase rls_disabled_in_public).
2430
+ -- 0.11.1 (iteration 35, #197): audit CHECK accepts 'rename-section'.
2428
2431
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2429
2432
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2430
2433
  -- sequence, so a store deploying this gets everything from both lines.
2431
- SELECT '0.11.0'::TEXT;
2434
+ SELECT '0.11.2'::TEXT;
2432
2435
  $$;
2433
2436
 
2434
2437
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.11.0
8
+ -- @version: 0.11.2
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -129,7 +129,10 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
129
129
  -- iteration 33: partial edits. Distinct from 'update-content'
130
130
  -- so the trail separates "added to" from "rewrote" from
131
131
  -- "removed" — one entry per operation in a cerefox_edit batch.
132
- 'insert', 'replace-section', 'delete-section')
132
+ 'insert', 'replace-section', 'delete-section',
133
+ -- iteration 35 (#197): renaming a heading is neither a
134
+ -- rewrite nor a removal, and the trail should say so.
135
+ 'rename-section')
133
136
  ),
134
137
  CONSTRAINT cerefox_audit_log_author_type_check CHECK (author_type IN ('user', 'agent'))
135
138
  );
@@ -428,6 +431,13 @@ ALTER TABLE cerefox_audit_log ENABLE ROW LEVEL SECURITY;
428
431
  ALTER TABLE cerefox_migrations ENABLE ROW LEVEL SECURITY;
429
432
  ALTER TABLE cerefox_config ENABLE ROW LEVEL SECURITY;
430
433
  ALTER TABLE cerefox_usage_log ENABLE ROW LEVEL SECURITY;
434
+ -- iteration 29 added this table and did not add it here, so it stayed
435
+ -- world-accessible on any project whose `anon` role holds the legacy
436
+ -- GRANT ... ON ALL TABLES IN SCHEMA public. Supabase's linter flagged it as
437
+ -- `rls_disabled_in_public` (2026-08-09). Every other table on this list denies
438
+ -- anon by having RLS on with NO policies; this one did not, so the model had a
439
+ -- hole exactly one table wide. See the guard test in _shared/__tests__.
440
+ ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
431
441
 
432
442
  -- ── Explicit Data API grants (issue #26; schema 0.8.2) ─────────────────────────
433
443
  -- Supabase is removing the implicit privileges the Data API roles get on
@@ -273,7 +273,7 @@ cerefox document get <document-id> --version-id <version-id>
273
273
  | Variable | Default | Description |
274
274
  |----------|---------|-------------|
275
275
  | `CEREFOX_BACKUP_DIR` | `~/.cerefox/backups` | Local directory where file system backups are stored. Created automatically if it doesn't exist. **Use an absolute path** — a relative value (such as the pre-v0.3.0 `./backups`) resolves against the current working directory, so snapshots scatter depending on where you run the command; `backup create` warns when it sees one. Does **not** follow `CEREFOX_CONFIG_DIR`, so a second environment must set it explicitly. |
276
- | `CEREFOX_ENV_LABEL` | _(unset)_ | Names a non-production environment (e.g. `staging`). Purely cosmetic and inert when unset. When set: the web UI shows a banner on every page, `doctor` shows `[LABEL]` on its title line, `backup create` puts the label in the snapshot filename and payload, and `backup restore` warns when a snapshot's environment differs from the target's. See [`staging-env.md`](staging-env.md). |
276
+ | `CEREFOX_ENV_LABEL` | _(unset)_ | Names a non-production environment (e.g. `staging`). Purely cosmetic and inert when unset. When set: the web UI shows a banner on every page, `doctor` shows `[LABEL]` on its title line, `backup create` puts the label in the snapshot filename and payload, and `backup restore` warns when a snapshot's environment differs from the target's, and `configure-agent` registers the MCP server as `cerefox-<label>` so a labelled environment sits alongside production instead of overwriting it (v1.4.0, #168). See [`staging-env.md`](staging-env.md). |
277
277
  | `CEREFOX_VERSION_RETENTION_HOURS` | **Retired in v1.1.0 — no longer read.** | Version retention is now a property of the store: `cerefox config set version_retention_hours <hours>`, or the **Settings** page. It moved because it used to be passed per-call from each client's environment, so the surviving history depended on which client wrote last. `cerefox doctor` reports the variable if it is still set. |
278
278
 
279
279
  ---
@@ -166,7 +166,20 @@ cerefox configure-agent --tool gemini # ~/.gemini/settings.json
166
166
  Useful flags: `--dry-run` (print the planned write without touching any file), `--json`
167
167
  (machine-readable result), `--config-path <path>` (override the target file), `--no-backup`
168
168
  (skip the `.pre-cerefox.bak` backup). The command is idempotent and backs up any existing
169
- config before writing. The per-client sections below document the same entries for anyone
169
+ config before writing.
170
+
171
+ The entry is registered under the server name `cerefox`. If `CEREFOX_ENV_LABEL` is
172
+ set — as it is for a [staging environment](staging-env.md) — the name becomes
173
+ `cerefox-<label>` instead, so a second environment sits **alongside** your production
174
+ entry rather than replacing it, and an agent can hold both at once (v1.4.0, #168).
175
+ The command prints the name it used.
176
+
177
+ A labelled entry additionally carries `CEREFOX_CONFIG_DIR` and `CEREFOX_ENV_LABEL`
178
+ in its `env` block. That is what makes it actually reach the environment it is named
179
+ after: MCP clients spawn a stdio server with the **client's** environment, not the
180
+ shell you ran `configure-agent` in, and a desktop client launched from the dock has
181
+ no shell environment at all. A production entry carries no `env` — unchanged from
182
+ earlier releases. The per-client sections below document the same entries for anyone
170
183
  who prefers to edit by hand or needs the remote (`Path A-Remote`) HTTP transport instead.
171
184
 
172
185
  ### Path A MCP tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",