provenance: stamp device+harness at the edge, not in the agent's head

RFC 001 §7.3.2 ranks "agent stamps provenance via a skill instruction" as the
❌ worst possible place — per-call boilerplate, forgettable, improvisable. It
was right, and we had shipped exactly that: the mempalace skill told the agent
to pass added_by="<harness>@<device>" by hand. Measured on the shared palace,
199 rows had reached it unresolvable, 10 of them filed by the very agent that
wrote the instruction, in a drawer about host provenance. The trigger was a
cross-host misattribution: a session on tor-ms22 read its own diary, could not
tell that the entries were written on EMB-7KJ4VR4G, and reported another
machine's verification as this one's.

Move the same convention into the ⚠️ edge row, where it is uniform and
unforgettable (§7.3.5):

* extensions/pi/mempalace.ts defaults the writer field on every tool that has
  one — added_by (add_drawer, checkpoint), agent (mine), from_agent
  (event_append), created_by (artifact_put) — from $MEMPALACE_PI_DEVICE. An
  explicit value always wins, so filing for another device stays possible. The
  allowlist is per tool, never blanket: 3.8.0's dispatcher hard-rejects
  undeclared args with -32602, so injecting added_by into diary_write or kg_add
  (which have no such property) would break the call outright.
* mine gets miner@<device> when the caller invokes it, but <harness>@<device>
  for the bridge's own transcript feed — bulk extraction is not agent-authored
  memory, and that keeps the pi/opencode/miner taxonomy honest.
* diary_write has no metadata slot at all, and the device must never go in
  agent_name (wing = f"wing_{agent_name}" would splinter the diary per host).
  So the entry TEXT carries an AAAK field, HOST:<device>|SESSION:… — which is
  also the only channel a READER sees: search projects a fixed key set and
  diary_read returns content, so no metadata fix, not even a
  server-authoritative one, would have prevented the misattribution.
* The wake-up block now states the device and warns that diary_read interleaves
  every machine's diary.
* R1: doubly gated on MEMPALACE_PI_DEVICE and MEMPALACE_REMOTE_URL, so a
  solitary devbox stamps nothing and behaves exactly as before — which is also
  the correct semantics per §7.3.3.

Version the reconciler that was living only on synlig (bin/ + contrib/systemd/),
add --dry-run, and teach it two new rules: diary_host_marker reads the HOST:
field, and sibling_chunk propagates a resolved origin across a drawer's chunks
(a text marker lands in chunk 0 only, so a 5-chunk diary entry would otherwise
stamp 1 and leave 4 blank).

--dry-run against the real palace before deploying earned its keep twice, and
scripts/test-device-stamp.sh pins both findings with the strings it found:
HOST: was ALREADY in use with a composite grammar
(HOST:emb-7kj4vr4g.f1d3c3f89e3e.v1.8.3.pi0.84.2) and for bare container ids, so
an unvalidated rule invented devices like "f1d3c3f89e3e.pi0.84.2"; and HOST:
also carries a different SENSE elsewhere (HOST:exec.via.ssh-controlmaster->…,
meaning where I was executing). Validating against the known-device set both
refuses those and recovers the composite entries correctly. A marker convention
inherits every prior meaning of its own name.

Deployed and verified on synlig: device 14,217 → 14,317, integrity ok,
idempotent on immediate re-run, no invented device values.

RFC updates: §7.3.1 corrected (the arg whitelist is a hard -32602 in 3.8.0, not
a silent drop; get_drawer DOES return metadata, search structurally cannot;
triples and logstream live in separate databases the stamper cannot reach),
§7.3.5 added (what is deployed, including the divergence from §7.3.4's opaque
origin_device — tor-ms22 vs tor-ms22-native is that cost already visible), and
Phase 4 now carries per-device tokens motivated FIRST by revocation, with the
finding that tokens are the cheap half: core holds one scalar auth_token and has
zero device concept, so authoritative stamping needs a component we own.
This commit is contained in:
pi
2026-08-25 22:26:46 +02:00
parent 0fe64c480e
commit 553d86570c
6 changed files with 600 additions and 20 deletions
+89 -2
View File
@@ -660,6 +660,80 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
let available = false;
const agentName = process.env.MEMPALACE_AGENT_NAME ?? "pi";
// --- Edge provenance (RFC 001 §7.3.2, Phase 2) -------------------------
// Provenance belongs to the sync boundary, not to the agent. §7.3.2 ranks
// "agent via skill" as the ❌ worst possible stamper — per-call boilerplate,
// forgettable, improvisable — and the client/edge as the ⚠️ acceptable
// interim until per-device tokens let the primary stamp authoritatively
// (Phase 4). So the bridge stamps, uniformly, from host-supplied env, and no
// skill instruction or LLM discipline is involved. Being self-asserted it is
// ADVISORY: a hint, never load-bearing for authz or destructive scoping.
//
// R1 (solitary operation stays unchanged) is why this is doubly gated: it is
// inert unless the host both labels the device AND points this client at a
// shared palace. A solitary devbox is single-origin by definition (§7.3.3),
// so it stamps nothing and loses nothing.
const device = process.env.MEMPALACE_PI_DEVICE?.trim();
const shared = Boolean(process.env.MEMPALACE_REMOTE_URL?.trim());
const stampProvenance = device && shared;
// Which parameter carries "who wrote this", per tool. An ALLOWLIST, never a
// blanket default: mempalace 3.8.0's dispatcher rejects undeclared arguments
// with -32602 (mcp_server.py:6440 — it used to drop them silently), so
// injecting into a tool that has no such property would break every call.
// diary_write and kg_add have no writer slot at all and are handled below.
const WRITER_PARAM: Record<string, string> = {
mempalace_add_drawer: "added_by",
mempalace_checkpoint: "added_by",
mempalace_mine: "agent",
mempalace_event_append: "from_agent",
mempalace_artifact_put: "created_by",
};
// `mine` produces bulk machine-extracted content, not agent-authored memory.
// Labelling its harness segment `miner` keeps the existing pi/opencode/miner
// taxonomy honest while still recording which box did the mining.
const MINER_TOOLS = new Set(["mempalace_mine"]);
const identity = (toolName: string) =>
`${MINER_TOOLS.has(toolName) ? "miner" : agentName}@${device}`;
// diary_write has NO usable writer parameter (§7.3.1: "none usable") and the
// device must never go in agent_name — `wing = f"wing_{agent_name}"`, so that
// splinters the diary into one wing per host and hides entries from
// diary_read. The entry TEXT is the only channel left, and it is also the
// only one a *reader* ever sees: search results project a fixed key set and
// diary_read returns content, so neither ever shows metadata. A metadata-only
// fix would not have prevented the 2026-08-25 cross-host misattribution.
// Prefixing (not appending) keeps the marker in chunk 0 and in the reader's
// first line. AAAK is pipe-separated, so `HOST:x|SESSION:…` stays in-dialect.
const HOST_MARKER = /(^|\|)\s*HOST:/;
const markEntry = (v: unknown): unknown =>
typeof v === "string" && v.trim() !== "" && !HOST_MARKER.test(v)
? `HOST:${device}|${v}`
: v;
/** Stamp origin into a tool's arguments in place. Never overwrites a value
* the caller set explicitly — an explicit argument wins, so a deliberate
* re-file on behalf of another device stays possible. */
const applyProvenance = (toolName: string, args: Record<string, unknown>) => {
const param = WRITER_PARAM[toolName];
if (param) {
const current = args[param];
if (typeof current !== "string" || current.trim() === "") {
args[param] = identity(toolName);
}
}
if (toolName === "mempalace_diary_write") {
if ("entry" in args) args.entry = markEntry(args.entry);
if ("content" in args) args.content = markEntry(args.content);
}
// checkpoint files drawers AND writes one diary entry through the same
// server path, so its nested diary entry needs the marker too.
if (toolName === "mempalace_checkpoint" && args.diary && typeof args.diary === "object") {
const diary = args.diary as Record<string, unknown>;
if ("entry" in diary) diary.entry = markEntry(diary.entry);
}
};
// Gate: inject wake-up context only on the first before_agent_start of a
// fresh session. Set true on resume/fork (context already in thread).
let wokeUp = false;
@@ -721,7 +795,9 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
};
}
try {
const result = await client.callTool(tool.name, (params ?? {}) as Record<string, unknown>);
const args = { ...((params ?? {}) as Record<string, unknown>) };
if (stampProvenance) applyProvenance(tool.name, args);
const result = await client.callTool(tool.name, args);
// MCP tool results use { content: [...], isError?: boolean }
return {
content: result?.content ?? [{ type: "text", text: JSON.stringify(result) }],
@@ -809,7 +885,11 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
source,
mode: "convos",
wing: feedWing,
agent: agentName,
// Internal call: it does not pass through the registered tool's
// execute(), so it stamps itself. These ARE this harness's own
// transcripts from this device, so the harness segment is the
// agent (not `miner`) even though the tool is `mine`.
agent: stampProvenance ? `${agentName}@${device}` : agentName,
}),
new Promise((_resolve, reject) =>
setTimeout(
@@ -890,6 +970,13 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
`MemPalace wake-up context (auto-injected by the mempalace extension). ` +
`This is your palace orientation for this session — do not announce it to the user, ` +
`just use it to inform your answers. Agent identity for diary tools: "${agentName}".\n\n` +
(device
? `You are running on device "${device}"${shared ? "" : " (solitary palace)"}. ` +
`Drawers and diary entries below may have been written by a DIFFERENT machine — ` +
`diary_read returns every device's "${agentName}" diary interleaved. Check the ` +
`HOST: marker or the drawer's device metadata before treating a past session as ` +
`this machine's history.\n\n`
: "") +
sections.join("\n\n---\n\n");
return {