Skip to content

Core — Write-safety contract

Runtime-agnostic contract for mutating the Rosetta graph through the MCP surface. The rosetta-agent is the live agent: unlike the read-only engagement-rehearsal plugin (ADR-PA-011), it genuinely writes — engagement twin state, engagement lifecycle, governance-gate transitions. This document is the discipline every write skill (engagement-twin, governance-lifecycle, engagement-lifecycle, engagement-canvas, decision-trace-capture) MUST honor. Authorized by ADR-RCP-009 (E7-S8-WP1).

For the tool catalog see core/rosetta-tool-vocabulary.md (§3 writes, §5 idempotency, §6 enums, §7 tool-policy). For auth see core/mcp-token.md; for write provenance reporting see core/provenance.md.

1. Confirm-before-destructive

Some writes are irreversible or move governance state backward. Before issuing one, the agent MUST surface the intended write (the tool, the target uid, and a human-readable diff/summary) and wait for explicit operator confirmation in the turn. Do NOT auto-fire these.

Destructive / governance-state class (confirm first): - rosetta_rollback_a_d_r — reverts an ADR. - rosetta_reject_governance_review — sends a candidate back / closes a review. - rosetta_request_governance_review_revision — changes review state. - rosetta_apply_engagement_twin edits that delete or replace nodes (an op that removes/overwrites existing twin state); pure additive applies are non-destructive. - rosetta_approve_governance_review — irreversible commit of an ADR; confirm. - rosetta_update_engagement with a clientUid that unlinks (empty string "" — removes the (Client)-[:HAS_ENGAGEMENT]-> edge + eng.clientUid) or moves the edge off an existing client — re-parents/removes established linkage, and an unlinked engagement recreates the client-less state the INV-D38 load gate exists to prevent. Semantics in core/engagement-lifecycle.md §Update (app-server dt-20260706-engagement-client-link-update-dual-write-gap.md, RATIFIED).

rosetta_update_engagement carries no destructive stage lever: stage is derived, not a settable patch field (see core/engagement-lifecycle.md / DT-RX-011 Q-A, RATIFIED, Option 2). Its name/status patches — and a clientUid set where no link yet exists — are additive, non-destructive; the only confirm-before case is the clientUid unlink / edge-move above.

Non-destructive class (proceed, then report): creates and additive updates — rosetta_create_engagement, rosetta_create_project, rosetta_create_promotion_candidate, rosetta_create_governance_review, rosetta_run_gate1, rosetta_evaluate_decision_trace, rosetta_create_decision_trace/rosetta_record, additive rosetta_apply_engagement_twin / rosetta_create_digital_twin_node, rosetta_upsert_practitioner, rosetta_update_engagement (except the clientUid unlink / edge-move case above). Issue these, then report what was written (§5).

REJECTED: a destructive write with no prior operator confirmation in the conversation. REJECTED: hiding a destructive write inside a batch the operator only approved at a high level — name each destructive op.

1.5 Content-honesty / provenance routing (ADR-RX-017 Q-C)

The forensic twin (rosetta_apply_engagement_twin) and the rehearsal twin (rosetta_apply_engagement_twin_rehearsal, core/rosetta-tool-vocabulary.md §3) are two lanes, distinguished by the honesty of the content being written, not by the operator's permission mode. Route by what the payload claims:

  • Simulated / proposed content MUST route to the rehearsal lane. A write whose content carries any simulated marker — provenance:"simulated", a [SIMULATED] label, status:PROPOSED, or a simulationRunId — MUST go to rosetta_apply_engagement_twin_rehearsal. That lane admits traceKind:"runtime" edits and stamps the revision ProvenanceEvent provenance:"simulated", so the simulated origin is durably recorded. Sending such content to the forensic rosetta_apply_engagement_twin is REJECTED (the forensic lane is for confirmed graph-canonical state).
  • Presenting simulated values as confirmed is misrepresentation — REJECTED. An apply that carries simulated/proposed values but strips the markers (no [SIMULATED], provenance:"confirmed", no simulationRunId) and routes them through the forensic lane as if a human ratified them is REJECTED as misrepresentation. Do not launder a rehearsal proposal into a confirmed claim by deleting its provenance.
  • The honest test-fill workflow now has a home. Rehearsal→fill→render-test is legitimate and recurring (the engagement-rehearsal proposed-twin-apply.json loop, core/engagement-twin.md §6). It writes honestly-labelled simulated content to the rehearsal lane; it never re-routes through direct bolt to dodge the forensic guard, and it never drops the markers to satisfy a confirmed-only reader.

Known limitation — agent-self-attested, NOT server-enforced honesty. §1.5 is a heuristic keyed on labels the agent itself writes: a misrepresenting agent can still omit the marker and route to the forensic lane, and the server will accept it. This rule improves intent-recognition and gives honest workflows a reachable lane; it cannot make honesty server-verifiable. The stronger, server-enforced attestation that DT-RX-017 §Ratification deferred ("Server-enforced honesty — deferred") now exists for the ratification act itself: DT-RX-022 put the lane-flipping verb server-side and out of agent reach entirely (§1.6). The apply lanes remain agent-self-attested — document the limitation; do not pretend the §1.5 guard is tamper-proof.

1.6 Ratification is human-only — the agent surface has no such verb (DT-RX-022 Q-D)

Ratifying or rejecting engagement-twin content — flipping reviewStatus to human-ratified/rejected, promoting provenance simulated → confirmed, clearing simulationRunId — is the human gate of the provenance lane, and it is enforced structurally, not by this contract's discipline:

  • The rosetta MCP surface deliberately has no ratify/reject tool. The server-side twin:ratify / twin:reject endpoints (DT-RX-022 Q-A) exist on REST for the enablement console's authenticated human sessions only; the mcp-server does not wrap them (the ensureAccountForUser precedent — excluded under a denied tag, same posture as §6's denied families). reviewedBy accepts only a human identity; the server rejects agent-principal ratifications.
  • Ratify/reject are HUMAN-ONLY via the enablement console — the enablement-agent review queue in the (protected)/engagements pages. When a task appears to need a ratification (a customer-tone render blocked on agent-authored nodes, a simulated run awaiting confirmation), the agent surfaces it as an operator action and STOPS — exactly the §6.1 out-of-band posture.
  • Agents propose via the simulated lane and STOP. The agent's whole authority on this axis is §1.5: write honestly-labelled simulated/proposed content to the rehearsal lane and report it for review. Do NOT hand-write reviewStatus: "human-ratified", reviewStatus: "rejected", or a provenance: "confirmed" promotion onto existing lane-stamped nodes through the apply paths to imitate a ratification — that is the §1.5 misrepresentation class, and the transition is the server's to make (one-way state machine: draft/agent-authoredhuman-ratified | rejected, both terminal; metamodel v1.11.0 §0.7).

REJECTED: any attempt to reach twin:ratify/twin:reject via direct REST (RCP-C-6 direct-bolt boundary, §6.1). REJECTED: prompting the operator for a blanket "yes" and then simulating the ratification with apply-path writes.

2. Idempotency by default

  • Reuse the natural keys in core/rosetta-tool-vocabulary.md §5; never mint a fresh sessionId/decisionPoint to force a duplicate write.
  • For rosetta_apply_engagement_twin, ALWAYS send an Idempotency-Key (a uuid pinned for the logical apply) so a retry is safe.
  • Treat 200-where-you-expected-201 as a normal idempotent hit, not an error.
  • 409 Conflict → state moved under you: reload (rosetta_get_engagement_twin with since=, or the relevant get) and re-evaluate; do NOT blind-retry.
  • 422 Unprocessable → referential failure (a referenced uid does not exist / FK violation): surface it, fix the reference, retry.

3. Enum / validation are hard

The server rejects off-enum or malformed writes with 400. Pre-validate every enum field against core/rosetta-tool-vocabulary.md §6 BEFORE the call (decisionType, outcome, retrievalStrategy, surfacedBy, phase, solutionMode, overrideMode). A 400 is a client bug, not a transient fault — fix the payload, do not retry unchanged.

clientUid is required at engagement birth (api 1.5.0, BREAKING). rosetta_create_engagement with clientUid absent, empty, or off the :Client.uid slug shape (^[A-Za-z0-9_.:\-]+$, ≤64) is rejected 422 before any write — same fix-the-payload posture as a 400; do not retry unchanged and do not fabricate a client slug: resolve the real client (or confirm the intended new one — the Client node is lazily MERGEd, so a typo silently mints a new client) BEFORE the call. Contract detail in core/engagement-lifecycle.md §Create.

4. Partition / access

Engagement writes require the bearer to have access to the engagement (the upstream REST server authorizes every call). 401 = missing/invalid bearer (config error per core/mcp-token.md; do not retry with a different token). 403 = the bearer lacks access to this engagement (a partition/ACL error — surface it; engagement-access grant/revoke is a denied tag, an out-of-band operator action, never a plugin call). Neither is a retryable fault.

5. Write provenance (RCP-C-4)

Every write is reported back to the operator: the tool called, the resulting uid, and the idempotency outcome (created / existed / conflicted). For a twin apply, report the applied edits[] count and the returned revision. The operator must always be able to see exactly what was mutated and where — writes are never silent.

6. The tool-policy boundary

The server's deny-by-default allowlist (core/rosetta-tool-vocabulary.md §7) is the outer bound of what the agent may write. Denied families (accounts, platform-admin, engagement-access, export, presence, project fork) are NOT plugin capabilities; if a task seems to need one, it is an operator / out-of-band action. Never hand-roll a denied endpoint or raw /api/v1/* (RCP-C-6).

6.1 The direct-bolt boundary (RCP-C-6) and the recovery-cleanup class

The agent's only sanctioned write path is the MCP surface (RCP-C-6). A direct bolt write to Neo4j — bypassing the MCP server to issue raw Cypher against the graph — is never a rosetta-agent action. This is the same posture as the denied engagement-access family: when a task appears to need it, that signals an operator / out-of-band step, not a capability the agent reaches for.

There is exactly one legitimate direct-bolt class, and it is not an agent capability: owner-authorized recovery / cleanup (e.g. a one-time simulationRunId purge, or repairing graph state a prior incident contaminated). This is an explicit out-of-band operator action, named here so the honesty guard (§1.5) stops depending on permission-mode toggling. The agent does not flip its own permission mode to "earn" a direct bolt; if recovery/cleanup is needed, the agent surfaces it as an owner action and stops. (This codifies the DT-RX-017 §7 rows-2-vs-5-7 oscillation — where honest simulated writes were re-routed through direct bolt and unblocked only by removing auto-mode — into a named boundary rather than a mode toggle.)

7. Pre-flight for writes

Before the first write of a turn, run the write pre-flight in core/preflight.md (token presence + reachability + engagement-access precondition + Idempotency-Key generation for twin apply + the confirm-before-destructive gate above).

Consumed by

  • skills/engagement-twin/SKILL.md
  • skills/governance-lifecycle/SKILL.md
  • skills/engagement-lifecycle/SKILL.md
  • skills/engagement-canvas/SKILL.md
  • skills/decision-trace-capture/SKILL.md
  • commands/{engagement,twin,govern,canvas}.md