# 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_digital_twin_node` / `rosetta_update_digital_twin_node_by_key`
  patches that remove or overwrite an existing property
  (`core/engagement-twin.md` §4).
- `rosetta_ingest_content` without `dryRun: true` (a commit) — and only against
  a non-production server; against production it is **REJECTED** until the
  DT-RX-036 switch (§1.7).
- `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).

> **On a platform admin's key this rule is the only interlock.** The governance
> decision verbs and canonical node writes stay on the agent surface, and the
> upstream admits them for a platform admin (app-server
> `gate3-governance-authz.md` Q-C-2; `pre-switch-write-lane-authz.md` Q-B-1).
> Confirm-before is then a client-side courtesy, not a control — honor it every
> time.

> `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), and `rosetta_ingest_content` with
`dryRun: true` (it writes nothing). 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` §3.5). 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-authored` → `human-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.

## 1.7 Content ingest — dry runs only before the switch (rosetta-platform E9-I1)

`rosetta_ingest_content` (mcp v0.11.0) lands a plugin run's canonical bundle in
the live graph in one transaction, and it needs a platform admin's key (403
otherwise). Until the **DT-RX-036 switch** (rosetta-platform manifest §11B,
E9-I1), the seeds are the content authority and **no plugin submits to the
production ingestion API**:

- **Against production, send `dryRun: true` only.** A dry run validates the
  whole bundle and returns the plan (`status: planned`, 200) without writing.
  The plugin's default `ROSETTA_API_BASE` is production.
- **A commit** (`dryRun` absent or false) is for a non-production server, at the
  operator's direction, confirm-before (§1). It returns 201 with the
  IngestionEvent; created patterns land `draft`, ADRs and BusinessIntents
  `proposed`.
- REJECTED: committing a bundle to production before the switch, whatever the
  key and whatever the operator's permission mode.

## 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** → the payload broke a rule: a referenced uid does not
  exist / FK violation, a node write that would change the key addressing the
  node, a create or patch on an own-lane label, a `baseVersion` on an engagement
  node's patch, or a refused ingest bundle (the body lists every failure).
  Surface it, fix the payload, retry.
- For a patch to a node outside any engagement (canonical content), send
  `baseVersion` from your last read so a concurrent edit is a 409, not a silent
  overwrite. Do not send it for an engagement node: that node keeps time on its
  engagement revision, and a `baseVersion` there is 422.

## 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`, and `gapType` on a turn projection's gaps, which
also need a `description`; `core/conversation-lifecycle.md` §12). 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 `MERGE`d, so a typo silently
mints a new client) BEFORE the call. Contract detail in
`core/engagement-lifecycle.md` §Create.

## 4. Partition / access

The upstream REST server authorizes every call against the bearer's user
(`core/rosetta-tool-vocabulary.md` §7.1). **401** = missing/invalid bearer
(config error per `core/mcp-token.md`; do not retry with a different token).
**403** has two readings:

- **On a governance tool (reads included), a canonical node write, any node
  create, or `rosetta_ingest_content`: this key isn't a platform admin.** Say
  so plainly and stop. Platform admin is granted out of band by an existing
  platform admin; the `platform-admin` tag is denied on this surface.
- **On an engagement-scoped call:** the bearer has no grant on that engagement,
  or, for a write, is not its lead or the account's owner/admin (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 401 nor 403 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`
