@rubytech/create-maxy 1.0.744 → 1.0.746

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 (29) hide show
  1. package/dist/__tests__/peer-brand-detect.test.js +103 -0
  2. package/dist/index.js +63 -4
  3. package/dist/peer-brand-detect.js +39 -0
  4. package/package.json +1 -1
  5. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts +37 -0
  6. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
  7. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
  8. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
  9. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
  10. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
  11. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
  12. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
  13. package/payload/platform/lib/graph-mcp/dist/index.js +146 -3
  14. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  15. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
  16. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  17. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
  18. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  19. package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
  20. package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
  21. package/payload/platform/lib/graph-mcp/src/index.ts +216 -4
  22. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
  23. package/payload/platform/plugins/docs/references/deployment.md +2 -1
  24. package/payload/platform/templates/specialists/agents/database-operator.md +3 -22
  25. package/payload/server/chunk-SPTD7L7Z.js +9474 -0
  26. package/payload/server/maxy-edge.js +1 -1
  27. package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
  28. package/payload/server/public/graph.html +1 -1
  29. package/payload/server/server.js +1 -1
@@ -38,7 +38,16 @@ import {
38
38
  formatAuditLine,
39
39
  type AuditWarning,
40
40
  } from "../../graph-write/dist/audit.js";
41
- import { SchemaCache, neo4jSchemaFetcher } from "./schema-cache.js";
41
+ import { SchemaCache, getSharedDriver, neo4jSchemaFetcher } from "./schema-cache.js";
42
+ import { rewriteWithProvenanceStamps } from "./cypher-rewrite-stamp.js";
43
+ import {
44
+ OrphanRollbackError,
45
+ runWriteTxBody,
46
+ synthesiseOrphanRollback,
47
+ synthesiseShimError,
48
+ synthesiseWriteResponse,
49
+ type GraphDriver,
50
+ } from "./cypher-shim-write.js";
42
51
 
43
52
  const SERVER_NAME = "graph";
44
53
  const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
@@ -443,6 +452,174 @@ function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string
443
452
  return JSON.stringify(wrapped);
444
453
  }
445
454
 
455
+ // --- Task 797: shim-owned write path -----------------------------------
456
+ // write_neo4j_cypher is intercepted (not forwarded to upstream). The shim
457
+ // runs the operator's cypher inside its own driver session under
458
+ // `executeWrite`, so provenance auto-stamping and orphan-rollback are
459
+ // transactionally atomic. Reads still pass through to upstream unchanged.
460
+ // Pure helpers (runWriteTxBody, synthesise*, OrphanRollbackError) live in
461
+ // cypher-shim-write.ts so they can be unit-tested without booting this
462
+ // module's stdin/stdout pipe and uvx spawn.
463
+
464
+ async function runShimWriteCypher(
465
+ id: string | number,
466
+ cypherFull: string,
467
+ cypherPrefix: string,
468
+ sessionIdParam: string | null,
469
+ operatorArgs: Record<string, unknown> | undefined,
470
+ startMs: number,
471
+ writeUnknownTokens: UnknownToken[],
472
+ validated: boolean,
473
+ ): Promise<void> {
474
+ const agentName = process.env.AGENT_SLUG ?? "unknown";
475
+ const sessionIdField =
476
+ sessionIdParam ?? process.env.SESSION_ID ?? "unknown";
477
+ const operatorParams =
478
+ (operatorArgs?.["params"] as Record<string, unknown> | undefined) ?? {};
479
+ const safePrefix = cypherPrefix.replace(/"/g, "'");
480
+
481
+ let rewrite: ReturnType<typeof rewriteWithProvenanceStamps>;
482
+ try {
483
+ rewrite = rewriteWithProvenanceStamps(cypherFull);
484
+ } catch (err) {
485
+ // Defensive — the rewriter is regex-only with no catastrophic-backtrack
486
+ // patterns, but a thrown error before driver acquisition would otherwise
487
+ // emit no [graph-query] line at all (review F3).
488
+ const errMsg = err instanceof Error ? err.message : String(err);
489
+ console.error(
490
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="rewrite-failed: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
491
+ );
492
+ process.stdout.write(
493
+ `${synthesiseShimError(id, "cypher rewrite failed — write NOT executed", errMsg)}\n`,
494
+ );
495
+ return;
496
+ }
497
+ if (rewrite.stampsAppended > 0) {
498
+ console.error(
499
+ `[graph-cypher-write] auto-stamp applied query="${safePrefix}" creates=${rewrite.stampsAppended}`,
500
+ );
501
+ }
502
+
503
+ let driver: GraphDriver;
504
+ try {
505
+ driver = (await getSharedDriver(resolvedNeo4jUri, neo4jUser, neo4jPassword)) as GraphDriver;
506
+ } catch (err) {
507
+ const errMsg = err instanceof Error ? err.message : String(err);
508
+ console.error(
509
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="driver-unavailable: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
510
+ );
511
+ process.stdout.write(
512
+ `${synthesiseShimError(id, "graph driver unavailable — write NOT executed", errMsg)}\n`,
513
+ );
514
+ return;
515
+ }
516
+
517
+ const session = driver.session();
518
+ let outcome: { nodesCreated: number; relsCreated: number; propertiesSet: number; serializedRecords: Array<Record<string, unknown>> } | null = null;
519
+
520
+ try {
521
+ outcome = await session.executeWrite((tx) =>
522
+ runWriteTxBody(tx, rewrite.cypher, operatorParams, {
523
+ agent: agentName,
524
+ session: sessionIdField,
525
+ }),
526
+ );
527
+ } catch (err) {
528
+ if (err instanceof OrphanRollbackError) {
529
+ console.error(
530
+ `[graph-cypher-write] orphan-rollback query="${safePrefix}" orphanLabels=${err.sampleLabels.join(",")}`,
531
+ );
532
+ console.error(
533
+ formatAuditLine({
534
+ kind: "orphan-warning",
535
+ cypherPrefix,
536
+ orphanIds: err.orphanIds,
537
+ }),
538
+ );
539
+ console.error(
540
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rolledBack=true orphans=${err.orphanIds.length} validated=${validated} ms=${Date.now() - startMs}`,
541
+ );
542
+ process.stdout.write(
543
+ `${synthesiseOrphanRollback(id, err.orphanIds.length, err.sampleLabels)}\n`,
544
+ );
545
+ } else {
546
+ const errMsg = err instanceof Error ? err.message : String(err);
547
+ console.error(
548
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
549
+ );
550
+ process.stdout.write(
551
+ `${synthesiseShimError(id, "Neo4j error", errMsg)}\n`,
552
+ );
553
+ }
554
+ return;
555
+ } finally {
556
+ await session.close().catch(() => {
557
+ /* session already closed by the driver on commit/rollback */
558
+ });
559
+ }
560
+
561
+ // outcome is non-null on the success path (the catch above returns early
562
+ // on rollback / shim error). Defensive narrow.
563
+ if (!outcome) return;
564
+
565
+ console.error(
566
+ `[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rows=${outcome.serializedRecords.length} validated=${validated} ms=${Date.now() - startMs}`,
567
+ );
568
+ console.error(
569
+ formatAuditLine({
570
+ kind: "accepted",
571
+ cypherPrefix,
572
+ nodesCreated: outcome.nodesCreated,
573
+ relsCreated: outcome.relsCreated,
574
+ agentName,
575
+ sessionId: sessionIdField,
576
+ }),
577
+ );
578
+
579
+ for (const t of writeUnknownTokens.filter((u) => u.kind === "relationship")) {
580
+ console.error(
581
+ formatAuditLine({
582
+ kind: "unknown-type-warning",
583
+ cypherPrefix,
584
+ type: t.token,
585
+ }),
586
+ );
587
+ }
588
+
589
+ const auditWarnings: AuditWarning[] = auditCypherWrite({
590
+ cypher: rewrite.cypher,
591
+ schema: schemaCache.snapshot(),
592
+ agentName,
593
+ sessionId: sessionIdField,
594
+ nodesCreated: outcome.nodesCreated,
595
+ relsCreated: outcome.relsCreated,
596
+ orphanIds: [],
597
+ });
598
+ for (const w of auditWarnings) {
599
+ if (w.kind === "missing-provenance-warning") {
600
+ console.error(
601
+ formatAuditLine({
602
+ kind: "missing-provenance-warning",
603
+ cypherPrefix,
604
+ created: w.created,
605
+ stamped: w.stamped,
606
+ }),
607
+ );
608
+ }
609
+ // unknown-type already emitted from the validator's writeUnknownTokens above;
610
+ // orphan-warning fires only on the rollback path.
611
+ }
612
+
613
+ process.stdout.write(
614
+ `${synthesiseWriteResponse(id, {
615
+ nodesCreated: outcome.nodesCreated,
616
+ relsCreated: outcome.relsCreated,
617
+ propertiesSet: outcome.propertiesSet,
618
+ records: outcome.serializedRecords,
619
+ })}\n`,
620
+ );
621
+ }
622
+
446
623
  type RequestDecision = "forward" | "intercepted";
447
624
 
448
625
  function handleRequestLine(line: string): RequestDecision {
@@ -481,6 +658,36 @@ function handleRequestLine(line: string): RequestDecision {
481
658
  return "forward";
482
659
  }
483
660
 
661
+ // Task 797: writes are intercepted into the shim-owned driver path.
662
+ // Helper so each branch below routes consistently.
663
+ const interceptWrite = (): RequestDecision => {
664
+ void runShimWriteCypher(
665
+ msg.id as string | number,
666
+ cypherFull,
667
+ cypherPrefix ?? "",
668
+ sessionIdParam,
669
+ msg.params?.arguments,
670
+ entry.startMs,
671
+ entry.writeUnknownTokens,
672
+ entry.validated,
673
+ ).catch((err) => {
674
+ // Defensive catch — runShimWriteCypher already handles its own errors,
675
+ // but never let an unexpected throw leak past this point.
676
+ const errMsg = err instanceof Error ? err.message : String(err);
677
+ console.error(
678
+ `[graph-mcp] runShimWriteCypher unhandled error: ${errMsg.replace(/"/g, "'")}`,
679
+ );
680
+ try {
681
+ process.stdout.write(
682
+ `${synthesiseShimError(msg.id as string | number, "shim-internal error", errMsg)}\n`,
683
+ );
684
+ } catch {
685
+ /* stdout closed */
686
+ }
687
+ });
688
+ return "intercepted";
689
+ };
690
+
484
691
  const snapshot = schemaCache.snapshot();
485
692
  const cacheReady = schemaCache.ready();
486
693
 
@@ -488,6 +695,11 @@ function handleRequestLine(line: string): RequestDecision {
488
695
  console.error(
489
696
  `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
490
697
  );
698
+ if (isWriteCall) {
699
+ // Task 797: still rewrite + run inside shim tx even when validator
700
+ // is cold. Auto-stamp + orphan rollback don't depend on schema.
701
+ return interceptWrite();
702
+ }
491
703
  pending.set(msg.id, entry);
492
704
  return "forward";
493
705
  }
@@ -516,6 +728,7 @@ function handleRequestLine(line: string): RequestDecision {
516
728
  console.error(
517
729
  `[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
518
730
  );
731
+ if (isWriteCall) return interceptWrite();
519
732
  pending.set(msg.id, entry);
520
733
  return "forward";
521
734
  }
@@ -530,13 +743,12 @@ function handleRequestLine(line: string): RequestDecision {
530
743
  // legitimately introduce new labels (REMOVE n:Old SET n:New) and edges
531
744
  // pending an ontology update. The post-write audit emits one
532
745
  // unknown-type-warning per unknown so operators see the gap; the cypher
533
- // forwards to upstream and commits.
746
+ // still commits via the shim-owned write path (Task 797).
534
747
  entry.writeUnknownTokens = result.unknown;
535
748
  console.error(
536
749
  `[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
537
750
  );
538
- pending.set(msg.id, entry);
539
- return "forward";
751
+ return interceptWrite();
540
752
  }
541
753
 
542
754
  entry.readWarnings = result.unknown;
@@ -156,20 +156,50 @@ export class SchemaCache {
156
156
  }
157
157
 
158
158
  /**
159
- * Build a SchemaFetcher over a long-lived neo4j-driver Driver. Each call
160
- * opens a fresh session (cheap, sub-ms) and closes it in finally so the
161
- * driver's connection pool stays small. Consumer owns the driver lifecycle.
159
+ * Module-scope driver singleton (Task 797). Both the schema-cache and the
160
+ * write-path (graph-mcp/src/index.ts `runShimWriteCypher`) borrow sessions
161
+ * from the same `neo4j-driver` Driver instance one connection pool per
162
+ * process. The first call binds the URI/credentials; subsequent calls
163
+ * return the same driver regardless of arguments. The driver lives for
164
+ * the process lifetime; closing it is the process exit's responsibility.
162
165
  *
163
- * Import is deferred to the factory call so that test files can exercise
164
- * the SchemaCache class without pulling neo4j-driver into the module graph.
166
+ * The import is deferred so test files can exercise the SchemaCache class
167
+ * without pulling neo4j-driver into their module graph.
168
+ */
169
+ let sharedDriverPromise: Promise<unknown> | null = null;
170
+
171
+ export async function getSharedDriver(
172
+ uri: string,
173
+ user: string,
174
+ password: string,
175
+ ): Promise<unknown> {
176
+ if (!sharedDriverPromise) {
177
+ // Clear the cached promise on rejection so a transient failure (e.g. a
178
+ // slow-to-import neo4j-driver during boot) doesn't wedge every subsequent
179
+ // call for the process lifetime (review F4).
180
+ sharedDriverPromise = (async () => {
181
+ const neo4j = await import("neo4j-driver");
182
+ return neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
183
+ })().catch((err) => {
184
+ sharedDriverPromise = null;
185
+ throw err;
186
+ });
187
+ }
188
+ return sharedDriverPromise;
189
+ }
190
+
191
+ /**
192
+ * Build a SchemaFetcher over the shared neo4j-driver Driver. Each call
193
+ * opens a fresh session (cheap, sub-ms) and closes it in finally.
165
194
  */
166
195
  export async function neo4jSchemaFetcher(
167
196
  uri: string,
168
197
  user: string,
169
198
  password: string,
170
199
  ): Promise<SchemaFetcher> {
171
- const neo4j = await import("neo4j-driver");
172
- const driver = neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
200
+ const driver = (await getSharedDriver(uri, user, password)) as {
201
+ session: () => { run: (q: string) => Promise<{ records: Array<{ get: (k: number) => unknown }> }>; close: () => Promise<void> };
202
+ };
173
203
  const query = async (cypher: string): Promise<string[]> => {
174
204
  const session = driver.session();
175
205
  try {
@@ -100,7 +100,8 @@ systemctl --user daemon-reload
100
100
  A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
101
101
 
102
102
  - **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Maxy on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
103
- - **Brand-isolated Neo4j (Task 787):** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so re-running another brand's installer never touches a peer brand's `neo4j-{brand}.service`.
103
+ - **Brand-isolated Neo4j (Task 787):** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
104
+ - **Peer-aware system-unit guard (Task 800):** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687 (Task 800)` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the Task 797 reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged from Task 787.
104
105
  - **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
105
106
 
106
107
  To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:
@@ -128,34 +128,15 @@ You wield two write surfaces. Wrapped writers (`memory-write`, `memory-update`,
128
128
 
129
129
  ### Graph stewardship doctrine — raw Cypher writes
130
130
 
131
- Four rules govern every raw Cypher write. The wrapped writers enforce these structurally for general agents; you internalise them because your write surface has no equivalent gate. The post-write `[graph-cypher-write]` audit emits a warning per discipline breach (`unknown-type-warning`, `missing-provenance-warning`, `orphan-warning`), but the audit is observational discipline is yours, not the audit's.
131
+ Two rules govern every raw Cypher write you author. They require LLM judgement the structural gate cannot make these calls for you. Two further rules (provenance, orphan-prevention) are now structurally enforced by the shim and need no prose discipline.
132
132
 
133
133
  **1. Every node has ≥1 typed edge in the same transaction.** A bare `CREATE (n:Person {name: 'Ian'})` is data, not knowledge — retrieval finds the node but it carries no context. Anchor every node creation to the relationship that explains it: `MATCH (anchor) WHERE … CREATE (anchor)-[:TYPE]->(n:Label {…})`, or `MERGE` the node and its edge in the same statement. *Why:* a graph that accumulates islands defeats relationship-traversal queries — the value of the graph is in the edges, not the rows.
134
134
 
135
135
  **2. Every edge type is in the live ontology.** Inventing types fragments retrieval — `KNOWS` ≠ `knows` ≠ `HAS_KNOWN`. Call `mcp__graph__maxy-graph-get_neo4j_schema` before authoring any write whose edge type you are not certain about; if no fitting type exists, stop and ask the admin agent for ontology guidance — never coin a synonym. *Why:* edge typology compounds over time. A synonym today blocks every future query that expected the canonical type, and the only fix is a label-rewrite Cypher pass that touches the same edge from both sides.
136
136
 
137
- **3. Every write carries provenance.** Stamp the same flattened fields the wrapped writers stamp on every node and every relationship the transaction creates:
137
+ **Structural enforcement (Task 797).** The shim auto-stamps `createdAt`, `createdByAgent`, `createdByTool`, `createdBySession` on every `CREATE`/`MERGE` alias before forwarding to Neo4j — you do not write these properties yourself. The shim runs the cypher inside a managed `executeWrite` and self-audits for unattached nodes before committing; if any node you created has zero edges in the same transaction, the entire transaction rolls back and you receive a structured error naming the orphan label(s). Treat the rollback as a hard failure (do not retry the same cypher); your job is to author atomic CREATE/MERGE-with-edge statements per Rule 1, not to write defensive WITH/MATCH/RETURN audits or hand-written SET clauses for `createdBy*` fields. The `[graph-cypher-write]` audit lines (`auto-stamp applied`, `accepted`, `orphan-rollback`, `orphan-warning`, `missing-provenance-warning`, `unknown-type-warning`) name what the structural enforcement saw — they are observation surfaces, not duties.
138
138
 
139
- ```cypher
140
- SET n.createdAt = datetime(),
141
- n.createdByAgent = 'database-operator',
142
- n.createdByTool = 'graph-cypher-write',
143
- n.createdBySession = $sessionId
144
- ```
145
-
146
- Apply the same SET to relationships (`SET r....`). The session id is the conversation correlation key; the admin dispatch passes it as a parameter. *Why:* a write without provenance cannot be attributed back to the dispatch that produced it. The post-write audit emits `missing-provenance-warning created=<n> stamped=<m>` when the static parser counts CREATE/MERGE statements that exceed the count of `createdBy*` tokens — this is the directional signal, not a structural gate.
147
-
148
- **4. Orphan prevention is your first-class duty.** Multi-statement transactions self-audit before completing. Capture the elementIds of every node the transaction creates, then run the orphan check inline:
149
-
150
- ```cypher
151
- WITH collect(elementId(n)) AS writtenIds
152
- MATCH (n) WHERE elementId(n) IN writtenIds AND NOT (n)--()
153
- RETURN count(n) AS orphans
154
- ```
155
-
156
- If `orphans > 0`, do not commit a "fix it up later" transaction — roll back, anchor the unattached nodes to their semantic parent, and re-run. *Why:* the hourly `[graph-health] orphans total=<N>` signal is your scorecard. A transaction that leaves orphans is a regression the role owns; the operator's stewardship is the primary defense against landfill.
157
-
158
- The four rules together replace the LOUD-FAIL improvisation pattern that prior versions of this prompt prescribed when a wrapped writer lacked an edge-between-existing-nodes path. You no longer loud-fail on missing graph-write tools — you have them. You loud-fail on credentials, on out-of-surface tools (a skill prescribing a non-graph MCP token you do not hold), and on dispatched skills whose prerequisites are unmet — exactly as the LOUD-FAIL prerogative names. Discipline failures within the graph-write surface produce audit warnings, not blockers; the role's prompt-internalised doctrine is the contract.
139
+ The two rules together replace the LOUD-FAIL improvisation pattern that prior versions of this prompt prescribed when a wrapped writer lacked an edge-between-existing-nodes path. You no longer loud-fail on missing graph-write tools — you have them. You loud-fail on credentials, on out-of-surface tools (a skill prescribing a non-graph MCP token you do not hold), and on dispatched skills whose prerequisites are unmet — exactly as the LOUD-FAIL prerogative names.
159
140
 
160
141
  ### Before writing any Cypher
161
142