Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Future peer: iOS app (Swift).
<!-- GENERATED by `coherence claude` from the spec+code graph. Do not edit by hand —
edit the *.spec.md files and re-run. Everything OUTSIDE these markers is authored prose. -->

_Derived from 8 components · 69 files · 529 symbols · 10 boundary claims._
_Derived from 8 components · 69 files · 530 symbols · 10 boundary claims._

## Component map (derived)

Expand Down Expand Up @@ -161,7 +161,7 @@ _files:_
| tools SSOT totality | Session | `TOOLS` | `tools SSOT totality` |
| served-content inertness | Routing | `inertHeaders` | `served-content inertness totality` |

<sub>Generated at 2026-06-25 03:39Z.</sub>
<sub>Generated at 2026-07-04 15:45Z.</sub>
<!-- coherence:end -->

## Current state
Expand Down
6 changes: 3 additions & 3 deletions mnemion-js/docs/coherence/_graph.html

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions mnemion-js/docs/coherence/graph.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-25 03:39Z",
"generatedAt": "2026-07-04 15:45Z",
"root": "mnemion-js",
"absRoot": "/Users/daniloc/Documents/Development/project-cambium/mnemion-js",
"nodes": [
Expand Down Expand Up @@ -1994,14 +1994,24 @@
"line": 63,
"prose": "Decide which gate a single mutate op must clear, purely from policy.ts. The\n MCP handler drives the round-trip mechanics off this; the engine independently\n enforces write-class, so this is the consent layer's single decision point."
},
{
"id": "s:entities/Hive/mutate-gate.ts#consentKey",
"parent": "f:entities/Hive/mutate-gate.ts",
"label": "consentKey",
"kind": "symbol",
"sub": "function",
"path": "entities/Hive/mutate-gate.ts",
"line": 101,
"prose": "The key used to arm/confirm a consent round-trip in `_pending_consent`. The\n request data is folded in so consent is bound to the SPECIFIC call shape, not\n to `(pattern, operation)` alone — re-issuing with different data must start\n a fresh round-trip rather than confirm the prior. (Without the data fold an\n agent could arm with benign data and re-issue with harmful data on the same\n pattern/op to satisfy the gate.) Data is serialized with sorted keys so\n semantically-identical re-issues confirm regardless of property order. Lives\n here because the data-binding is the correctness property of the gate; the\n round-trip MECHANICS stay in session.ts."
},
{
"id": "s:entities/Hive/mutate-gate.ts#BatchOp",
"parent": "f:entities/Hive/mutate-gate.ts",
"label": "BatchOp",
"kind": "symbol",
"sub": "interface",
"path": "entities/Hive/mutate-gate.ts",
"line": 79
"line": 107
},
{
"id": "s:entities/Hive/mutate-gate.ts#findGatedBatchOp",
Expand All @@ -2010,7 +2020,7 @@
"kind": "symbol",
"sub": "function",
"path": "entities/Hive/mutate-gate.ts",
"line": 86,
"line": 114,
"prose": "The first op in a batch that may NOT ride inside it, or null if all are\n eligible. Consent-gated escalations and patches on gated patterns must go\n through a single mutate (a batch would skip the round-trip / kernel hooks);\n archive (de-escalation) is allowed. Pure derivation of policy.ts — the batch\n rule has one home, not an inline `.find` in the handler."
},
{
Expand Down
28 changes: 28 additions & 0 deletions mnemion-js/entities/Hive/mutate-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ export function mutateGate(
return { kind: "pass" };
}

// === Round-trip key ===

/** Stable JSON serializer: keys sorted at every nesting level so the SAME logical
* value yields the SAME string regardless of property insertion order. The
* consent key depends on this — without it, an agent re-issuing the identical
* call with `{path, visibility}` vs `{visibility, path}` would arm a fresh
* round-trip instead of confirming the prior. */
function stableStringify(v: unknown): string {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
const o = v as Record<string, unknown>;
const keys = Object.keys(o).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
}

/** The key used to arm/confirm a consent round-trip in `_pending_consent`. The
* request data is folded in so consent is bound to the SPECIFIC call shape, not
* to `(pattern, operation)` alone — re-issuing with different data must start
* a fresh round-trip rather than confirm the prior. (Without the data fold an
* agent could arm with benign data and re-issue with harmful data on the same
* pattern/op to satisfy the gate.) Data is serialized with sorted keys so
* semantically-identical re-issues confirm regardless of property order. Lives
* here because the data-binding is the correctness property of the gate; the
* round-trip MECHANICS stay in session.ts. */
export function consentKey(pattern: string, operation: string, data: Record<string, unknown>): string {
return `consent:${pattern}:${operation}:${stableStringify(data)}`;
}

// === Batch eligibility ===

export interface BatchOp { pattern?: string; operation?: string; data?: { visibility?: unknown } }
Expand Down
4 changes: 2 additions & 2 deletions mnemion-js/entities/Session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
import { z } from "zod";
import type { HiveDO } from "../Hive/hive";
import { PRODUCT_NAME, URI_SCHEME, uri, HIVE_ID, OWNER_ACTOR } from "../../shared/core/constants";
import { mutateGate, findGatedBatchOp, normalizeMutateData, isSingleOpData } from "../Hive/mutate-gate";
import { mutateGate, findGatedBatchOp, normalizeMutateData, isSingleOpData, consentKey } from "../Hive/mutate-gate";
import { CHANGE_TYPE_NAMES } from "../Hive/evolution";
import { FORMAT_IDS } from "../../shared/core/format-palette";
import { TOOLS } from "./tools";
Expand Down Expand Up @@ -732,7 +732,7 @@ Note: tools may need to be loaded before first use. If a tool call fails, load i
// first call returns confirmation_required and the agent must re-issue
// the identical call to commit. archive (removal) is de-escalation and
// is allowed without one.
const confirmKey = `consent:${pattern}:${resolvedOp}:${JSON.stringify(singleData)}`;
const confirmKey = consentKey(pattern, resolvedOp, singleData);
if (!(await hive.checkAndArmConsent(confirmKey))) {
return {
content: [{
Expand Down
64 changes: 64 additions & 0 deletions mnemion-js/src/__tests__/consent-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// The consent round-trip's key is constructed from (pattern, operation, data).
// The data fold is load-bearing: without it, an agent could arm consent on a
// benign payload (e.g. visibility: private) and then re-issue the SAME pattern/op
// with a harmful payload (visibility: public) to satisfy the gate. The session
// handler used to build the key inline, with no unit covering the binding; this
// oracle locks the construction to (pattern, op, data) so a refactor that drops
// any of the three fails loudly instead of opening a consent-bypass.
import { describe, it, expect } from "vitest";
import { consentKey } from "../../entities/Hive/mutate-gate";

describe("consentKey — round-trip key construction", () => {
it("is deterministic for identical inputs", () => {
const a = consentKey("_publications", "create", { path: "x", visibility: "public" });
const b = consentKey("_publications", "create", { path: "x", visibility: "public" });
expect(a).toBe(b);
});

it("is property-order INSENSITIVE: same logical data with different insertion order produces the same key", () => {
// Without sorted-key serialization, an agent re-issuing the identical call
// but reconstructing the object would arm a fresh round-trip instead of
// confirming — a usability and a security bug (a different ordering could
// also bypass an in-flight arming for a benign payload).
const a = consentKey("_publications", "create", { path: "x", visibility: "public" });
const b = consentKey("_publications", "create", { visibility: "public", path: "x" });
expect(a).toBe(b);
});

it("is property-order INSENSITIVE in NESTED objects too", () => {
const a = consentKey("_pages", "create", { layout: { rows: 3, cols: 4 }, title: "t" });
const b = consentKey("_pages", "create", { title: "t", layout: { cols: 4, rows: 3 } });
expect(a).toBe(b);
});

it("namespaces consent rows with a `consent:` prefix so `_pending_consent` queries can't be confused for other keys", () => {
expect(consentKey("_members", "create", { label: "alice" }).startsWith("consent:")).toBe(true);
});

it("binds the DATA: differing payloads on the same pattern/op produce different keys", () => {
const benign = consentKey("_publications", "create", { path: "x", visibility: "private" });
const harmful = consentKey("_publications", "create", { path: "x", visibility: "public" });
expect(benign).not.toBe(harmful);
});

it("binds the PATTERN: same op/data on a different pattern produces a different key", () => {
const a = consentKey("A", "create", { x: 1 });
const b = consentKey("B", "create", { x: 1 });
expect(a).not.toBe(b);
});

it("binds the OPERATION: different op on the same pattern/data produces a different key", () => {
const a = consentKey("_members", "create", { id: 1 });
const b = consentKey("_members", "update", { id: 1 });
expect(a).not.toBe(b);
});

it("does NOT conflate two consent armings whose data differs only in one harmful facet", () => {
// The pilot scenario: arm consent for a benign create, then attempt to redeem
// it for an escalating create. The key must differ so the second call starts
// a fresh round-trip rather than consuming the benign arming.
const armed = consentKey("_documents", "create", { title: "x", visibility: "private" });
const escalate = consentKey("_documents", "create", { title: "x", visibility: "public" });
expect(armed).not.toBe(escalate);
});
});
Loading