feat: harden send preview idempotency

This commit is contained in:
zhaoyilun
2026-07-10 19:24:53 +08:00
parent 138615a29c
commit 93112d6d99
8 changed files with 482 additions and 22 deletions

View File

@@ -63,6 +63,7 @@ type smokeResult struct {
PacketLogDirConfiguredAfterRun bool `json:"packet_log_dir_configured_after_run"`
MsgLibConfigured bool `json:"msglib_configured"`
SendAuditPath string `json:"send_audit_path"`
SendIdempotencyPath string `json:"send_idempotency_path"`
}
func main() {
@@ -76,10 +77,18 @@ func run() error {
defer cancel()
clearRuntimeEnv()
auditPath := filepath.Join("runs", "capability-smoke", "send-preview.jsonl")
sendStateDir := filepath.Join("runs", "capability-smoke", fmt.Sprintf("state-%d", time.Now().UnixNano()))
if err := os.MkdirAll(sendStateDir, 0o755); err != nil {
return err
}
auditPath := filepath.Join(sendStateDir, "send-preview.jsonl")
if err := os.Setenv("ISPHERE_SEND_AUDIT_PATH", auditPath); err != nil {
return err
}
idempotencyPath := filepath.Join(sendStateDir, "send-idempotency.jsonl")
if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", idempotencyPath); err != nil {
return err
}
session, cleanup, err := newSession(ctx, "isphere-capability-smoke")
if err != nil {
@@ -160,6 +169,7 @@ func run() error {
PacketLogDirConfiguredAfterRun: os.Getenv("ISPHERE_PACKET_LOG_DIR") != "",
MsgLibConfigured: false,
SendAuditPath: auditPath,
SendIdempotencyPath: idempotencyPath,
}
encoded, err := json.Marshal(result)
if err != nil {

View File

@@ -30,7 +30,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
- `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`.
- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent.
- `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked.
- `isphere_send_message` accepts the R6b preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit metadata; `execution_mode="production"` returns blocked status with all side-effect flags false.
- `isphere_send_message` accepts the R6b/R6e preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit/idempotency metadata; `execution_mode="production"` returns blocked status with all side-effect flags false.
- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, C39 adds a Go receive-source adapter around `ListMessages`, C40 adds tool-level explicit `msglib_readonly` source selection, and C41 wires it through env configuration without changing the MCP default source.
- N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures.
- N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only.
@@ -58,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
## Current loop
Current loop: `R6d partial returned sandbox evidence intake accepted for limited use; next R6e sandbox-only send connector shell and idempotency audit hardening`.
Current loop: `R6e sandbox-only send connector shell and idempotency audit hardening implemented; next R6f fake/sandbox connector contract or corrected full evidence intake`.
Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
@@ -118,6 +118,7 @@ Current loop output so far:
45. R6c: because the local machine cannot log in, prepared an online/internal sandbox evidence package instead of attempting local send. Added `scripts/package-send-sandbox-gate.ps1`, `scripts/verify-send-sandbox-gate-package.ps1`, and `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; local verification generates `runs/send-sandbox-gate-package.zip` with the read-only recorder, before/after scripts, sandbox input template, SHA256 helper, expected-return checklist, and return-zip helper.
46. R6c package consolidation: upgraded `runs/send-sandbox-gate-package.zip` into a one-pass operator recording suite with `RUN-RECORDING-SUITE.bat`, numbered `01/02/03` scripts, and `RECORDING-PACKAGE-MANIFEST.json`; added a second offline-safe `runs/send-capability-test-package.zip` with `isphere-capability-smoke.exe` plus packaged `ISphereWinHelper.exe` to verify the 9-tool MCP surface, receive/search/file-list fixture behavior, send preview, and `production_send_enabled=false`.
47. R6d partial intake: accepted `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence only. Added `scripts\validate-returned-send-sandbox-package.ps1`, `scripts\test-validate-returned-send-sandbox-package.ps1`, and `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`. Validator reports `partial_evidence_usable=true` and `r6d_gate_pass=false`: capability test passed, content hash matched, idempotency key/success/no-second-send fields were present, but after-recorder output and manual send timestamps are missing. This can unblock sandbox-only connector-shell/idempotency work, not production send.
48. R6e: hardened `isphere_send_message` with a sandbox-only connector shell marker and idempotency store. Preview remains no-send; production remains blocked. Duplicate idempotency keys for the same target/content are detected and reported; conflicting reuse of the same idempotency key for different content/target is blocked with all side-effect flags false. `ISPHERE_SEND_IDEMPOTENCY_PATH` can point to a JSONL state file; standard verification uses per-run temporary state.
## Recompiled business roadmap
@@ -213,3 +214,12 @@ R6d partial returned sandbox evidence intake is complete:
- `partial_evidence_usable=true`: use it to continue sandbox-only send connector shell and idempotency/audit hardening.
- `r6d_gate_pass=false`: do not enable production `isphere_send_message`, because after-recorder output and manual send timestamps are missing.
- Next node: R6e sandbox-only send connector shell and idempotency audit hardening.
R6e sandbox-only send connector shell and idempotency audit hardening is complete:
- Added `SendMessageIdempotencyStore` and file-backed `ISPHERE_SEND_IDEMPOTENCY_PATH`.
- `isphere_send_message` response now exposes `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`.
- Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects.
- Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects.
- `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic.
- Next node: R6f fake/sandbox connector contract or corrected full evidence intake.

View File

@@ -100,6 +100,7 @@ The verification confirms:
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; `execution_mode="production"` returns `send_status="blocked"` and `production_send_enabled=false`.
- `isphere_send_message` also exposes sandbox-only connector metadata and idempotency state: duplicate reuse of the same key for the same target/content is detectable, while conflicting reuse of the same key for different content/target is blocked.
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
- File download/cache mapping, send-file, and production send remain later-stage work.
@@ -264,6 +265,15 @@ Allowed parameters:
- `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. C22 supports `mode` empty or `list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` and `output_dir` must be empty in current list-only mode; download is deferred.
- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. `execution_mode="production"` returns blocked status until sandbox-send evidence passes. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key.
Optional send audit/idempotency paths:
```powershell
$env:ISPHERE_SEND_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-preview.jsonl"
$env:ISPHERE_SEND_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-idempotency.jsonl"
```
If `ISPHERE_SEND_IDEMPOTENCY_PATH` is set, the send preview path records hashed idempotency state. Reusing the same idempotency key for the same target/content sets `duplicate_detected=true`; reusing it for different content or target returns blocked metadata with `conflict_detected=true`. Raw message body and raw idempotency key are not stored in the JSONL state.
## 10. Current tool surface and next-stage route
The current runbook verifies nine tools:
@@ -278,7 +288,7 @@ The current runbook verifies nine tools:
- `isphere_receive_files`
- `isphere_send_message`
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata and redacted audit only; production send remains blocked until dynamic observation, idempotency protection, and one approved sandbox send prove success/ack behavior.
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, and redacted audit only; production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate.

View File

@@ -26,7 +26,7 @@ Returned send sandbox analysis: `docs/source-discovery/2026-07-10-returned-send-
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | MsgLib `TD_ReceiveFileRecord` safe metadata through explicit copied-DB path; decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck`; `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`; `docs/source-discovery/2026-07-10-file-cache-mapping-diagnostic.md` | C22 verifies safe file-list contract args; R3 confirms list metadata is available; R3b diagnostic finds 0 current cache/archive matches, so R4 remains blocked | blocked pending better cache evidence or UI/client download connector |
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`; `internal/tools/isphere_send_message_test.go`; `cmd/isphere-capability-smoke/main.go` | R6d validates `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence only: `partial_evidence_usable=true`, `r6d_gate_pass=false`, capability smoke passed, content hash matched, operator success/no-second-send fields were present, but after-recorder output and send timestamps were missing. This is enough for sandbox-only connector-shell/idempotency work, not enough for production send. | next: R6e sandbox-only connector shell and idempotency audit hardening |
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`; `internal/tools/isphere_send_message_test.go`; `cmd/isphere-capability-smoke/main.go` | R6e adds sandbox-only connector-shell metadata and JSONL-backed idempotency state. Duplicate same-key/same-target/same-content preview calls are detectable; conflicting key reuse is blocked. Production remains blocked because R6d was only partial evidence and no real connector has passed a controlled online sandbox test. | next: R6f fake/sandbox connector contract |
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)` | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind returned message-send sandbox evidence plus later upload/file-message dry-run evidence. | next: wait for R6d, then file-send upload preflight |
## Stage C entry rule
@@ -44,4 +44,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac
## Deferred source work
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. R6d partial evidence is now usable only for sandbox-only send connector shell and idempotency/audit hardening. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6e.
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. R6e has completed sandbox-only send connector shell and idempotency/audit hardening. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6f.

View File

@@ -28,7 +28,7 @@
| Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata; R6d partial returned package is usable for sandbox-only connector-shell/idempotency work. | Full production still needs after-recorder output and manual send timestamps, or a later complete returned package. | next R6e sandbox-only connector shell |
| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata; R6e adds sandbox-only connector shell metadata and idempotency/audit state. | Full production still needs after-recorder output/manual timestamps or a controlled online sandbox connector pass. | next R6f fake/sandbox connector contract |
| Receive files | List mode exists from message-derived file metadata; R3/R3b confirm DB file metadata exists but current cache/archive evidence does not map to local files. | R4 download is blocked until better cache evidence or a UI/client download connector is available. | blocked; continue R5 |
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind returned send-message sandbox evidence plus upload/file-transfer dry-run. | wait for R6d, then file-send upload preflight |
@@ -551,6 +551,41 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
**Stop condition:**
- Do not wire a real send entrypoint until a full returned package or controlled online sandbox connector test is available.
**R6e Result:**
- Added `SendMessageIdempotencyStore` plus file-backed `fileSendMessageIdempotencyStore`.
- Added `ISPHERE_SEND_IDEMPOTENCY_PATH` for JSONL idempotency state.
- Added `RegisterISphereSendMessageToolWithState` so tests and future connector work can inject fake audit/idempotency state.
- `isphere_send_message` now returns `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`.
- Duplicate idempotency key reuse for the same target/content is reported with `duplicate_detected=true` and no side effects.
- Conflicting idempotency key reuse for different content/target is blocked with `conflict_detected=true`, `send_status="blocked"`, and no side effects.
- `scripts/verify-go-mcp.ps1` and `cmd/isphere-capability-smoke` now use fresh per-run send audit/idempotency state so repeated smokes remain deterministic.
- Decision: next node is R6f fake/sandbox connector contract. Production send remains blocked.
---
### R6f: Fake/sandbox send connector contract
**Purpose:** Define the connector interface and fake connector behavior needed before any real B-route send entrypoint is attached.
**Files:**
- Modify: `internal/tools/isphere_send_message.go`
- Modify tests: `internal/tools/isphere_send_message_test.go`
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/go-mcp-runbook.md`
**Execution policy:**
- The fake connector may simulate accepted/ack/failed outcomes only inside tests.
- Runtime MCP `execution_mode="production"` remains blocked unless an explicit test-only/fake injection is used.
- No real helper send op, no UI click/type, no network/protocol send, no upload.
**Acceptance gate:**
- Fake connector tests cover accepted, failed, duplicate, and idempotency-conflict paths.
- The production MCP server still reports `production_send_enabled=false`.
- Audit records keep raw message body and raw idempotency key out of JSONL.
**Stop condition:**
- If a real connector is introduced before fake/sandbox contract tests pass, stop and revert that part.
---
### R6: Send-message preview and production path

View File

@@ -1,6 +1,7 @@
package tools
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
@@ -9,18 +10,21 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
ToolNameSendMessage = "isphere_send_message"
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
sendMessageConnector = "implatform-sidecar"
sendMessagePreviewStage = "preview"
sendMessageBlockedStage = "blocked"
sendMessageProductionMode = "production"
ToolNameSendMessage = "isphere_send_message"
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
EnvSendMessageIdempotencyPath = "ISPHERE_SEND_IDEMPOTENCY_PATH"
sendMessageConnector = "implatform-sidecar"
sendMessageConnectorMode = "sandbox-only-shell"
sendMessagePreviewStage = "preview"
sendMessageBlockedStage = "blocked"
sendMessageProductionMode = "production"
)
type SendMessageArgs struct {
@@ -36,6 +40,10 @@ type SendMessageAuditSink interface {
AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error
}
type SendMessageIdempotencyStore interface {
ReserveSendMessageIdempotency(ctx context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error)
}
type SendMessageAuditEvent struct {
Tool string `json:"tool"`
TargetType string `json:"target_type"`
@@ -57,14 +65,46 @@ type SendMessageAuditEvent struct {
IdempotencyKey string `json:"-"`
}
type SendMessageIdempotencyRecord struct {
Tool string `json:"tool"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
ContentSHA256 string `json:"content_sha256"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
}
type SendMessageIdempotencyDecision struct {
Duplicate bool
Conflict bool
FirstAuditRef string
FirstStartedAt string
}
type fileSendMessageAuditSink struct {
path string
}
type fileSendMessageIdempotencyStore struct {
path string
}
var sendMessageIdempotencyFileMu sync.Mutex
func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) {
RegisterISphereSendMessageToolWithState(server, auditSink, nil)
}
func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore) {
if auditSink == nil {
auditSink = defaultSendMessageAuditSink()
}
if idempotencyStore == nil {
idempotencyStore = defaultSendMessageIdempotencyStore()
}
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSendMessage,
@@ -77,6 +117,11 @@ func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAud
}
finished := time.Now().UTC()
response, event := sendMessagePreviewResponse(normalized, started, finished)
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
if err != nil {
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
}
applySendMessageIdempotencyDecision(response, &event, decision)
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
}
@@ -91,6 +136,13 @@ func defaultSendMessageAuditSink() SendMessageAuditSink {
return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")}
}
func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore {
if path := strings.TrimSpace(os.Getenv(EnvSendMessageIdempotencyPath)); path != "" {
return fileSendMessageIdempotencyStore{path: path}
}
return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")}
}
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
if strings.TrimSpace(s.path) == "" {
return nil
@@ -113,6 +165,83 @@ func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, even
return nil
}
func (s fileSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
if strings.TrimSpace(s.path) == "" {
return SendMessageIdempotencyDecision{}, nil
}
if record.IdempotencyKeySHA256 == "" {
return SendMessageIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required")
}
sendMessageIdempotencyFileMu.Lock()
defer sendMessageIdempotencyFileMu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return SendMessageIdempotencyDecision{}, err
}
existing, err := readSendMessageIdempotencyRecords(s.path)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
for _, candidate := range existing {
if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
continue
}
decision := SendMessageIdempotencyDecision{
FirstAuditRef: candidate.AuditRef,
FirstStartedAt: candidate.StartedAt,
}
if candidate.TargetRef == record.TargetRef && candidate.ContentSHA256 == record.ContentSHA256 {
decision.Duplicate = true
return decision, nil
}
decision.Conflict = true
return decision, nil
}
payload, err := json.Marshal(record)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return SendMessageIdempotencyDecision{}, err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return SendMessageIdempotencyDecision{}, err
}
return SendMessageIdempotencyDecision{}, nil
}
func readSendMessageIdempotencyRecords(path string) ([]SendMessageIdempotencyRecord, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
var records []SendMessageIdempotencyRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record SendMessageIdempotencyRecord
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("decode idempotency record: %w", err)
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return records, nil
}
type normalizedSendMessageArgs struct {
TargetType string
TargetID string
@@ -228,12 +357,14 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
SideEffects: sideEffects,
}
response := map[string]any{
"ok": ok,
"send_status": status,
"blocked_reason": blockedReason,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_stage": stage,
"ok": ok,
"send_status": status,
"blocked_reason": blockedReason,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"production_send_enabled": false,
"target": map[string]any{
"target_type": input.TargetType,
"target_id": input.TargetID,
@@ -245,6 +376,10 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
},
"idempotency": map[string]any{
"idempotency_key_sha256": input.IdempotencyKeySHA256,
"duplicate_detected": false,
"conflict_detected": false,
"first_audit_ref": nil,
"first_started_at": nil,
},
"side_effects": sideEffects,
"audit": map[string]any{
@@ -252,6 +387,7 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
"source": sendMessageConnector,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"content_sha256": input.ContentSHA256,
"target_ref": input.TargetRef,
@@ -264,6 +400,58 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
return response, event
}
func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord {
return SendMessageIdempotencyRecord{
Tool: event.Tool,
TargetRef: event.TargetRef,
ExecutionMode: event.ExecutionMode,
Connector: event.Connector,
ConnectorStage: event.ConnectorStage,
ContentSHA256: event.ContentSHA256,
IdempotencyKeySHA256: event.IdempotencyKeySHA256,
AuditRef: event.AuditRef,
StartedAt: event.StartedAt,
}
}
func applySendMessageIdempotencyDecision(response map[string]any, event *SendMessageAuditEvent, decision SendMessageIdempotencyDecision) {
idempotency, _ := response["idempotency"].(map[string]any)
if idempotency == nil {
idempotency = map[string]any{}
response["idempotency"] = idempotency
}
idempotency["duplicate_detected"] = decision.Duplicate
idempotency["conflict_detected"] = decision.Conflict
if decision.FirstAuditRef != "" {
idempotency["first_audit_ref"] = decision.FirstAuditRef
}
if decision.FirstStartedAt != "" {
idempotency["first_started_at"] = decision.FirstStartedAt
}
if !decision.Conflict {
return
}
blockedReason := "idempotency key was already used for a different target or content"
response["ok"] = false
response["send_status"] = "blocked"
response["blocked_reason"] = blockedReason
response["connector_stage"] = sendMessageBlockedStage
audit, _ := response["audit"].(map[string]any)
if audit != nil {
audit["result"] = "blocked"
audit["connector_stage"] = sendMessageBlockedStage
audit["blocked_reason"] = blockedReason
}
if event != nil {
event.ConnectorStage = sendMessageBlockedStage
event.SendStatus = "blocked"
event.Result = "blocked"
}
}
func sendMessageNoSideEffects() map[string]any {
return map[string]any{
"sent_message": false,

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -12,8 +13,9 @@ import (
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, audit)
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
@@ -90,8 +92,9 @@ func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T)
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, audit)
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
@@ -145,7 +148,7 @@ func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{})
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
@@ -165,6 +168,171 @@ func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
}
}
func TestISphereSendMessageDetectsDuplicateIdempotencyKey(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
content := "duplicate preview"
contentHash := sha256HexForSendMessageTest(content)
args := map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-duplicate-1",
"execution_mode": "preview",
}
if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args}); err != nil {
t.Fatalf("first call: %v", err)
}
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args})
if err != nil {
t.Fatalf("second call: %v", err)
}
if second.IsError {
t.Fatalf("duplicate should be structured preview metadata, got error: %+v", second)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
Idempotency map[string]any `json:"idempotency"`
SideEffects map[string]any `json:"side_effects"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode duplicate payload %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" {
t.Fatalf("duplicate preview should remain planned/no-send: %s", payload)
}
if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false {
t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.Idempotency["first_audit_ref"] == "" {
t.Fatalf("duplicate response missing first_audit_ref: %#v", decoded.Idempotency)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false {
t.Fatalf("duplicate preview should not have side effects: %#v", decoded.SideEffects)
}
if len(audit.events) != 2 || audit.events[1].Result != "planned" {
t.Fatalf("audit events = %+v, want two planned events", audit.events)
}
}
func TestISphereSendMessageBlocksIdempotencyKeyConflict(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
})
defer cleanup()
firstContent := "first body"
firstHash := sha256HexForSendMessageTest(firstContent)
_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": firstContent,
"content_sha256": firstHash,
"idempotency_key": "idem-conflict-1",
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("first call: %v", err)
}
secondContent := "different body"
secondHash := sha256HexForSendMessageTest(secondContent)
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": secondContent,
"content_sha256": secondHash,
"idempotency_key": "idem-conflict-1",
"execution_mode": "preview",
}})
if err != nil {
t.Fatalf("conflict call: %v", err)
}
if second.IsError {
t.Fatalf("idempotency conflict should be structured blocked metadata, got error: %+v", second)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
ConnectorStage string `json:"connector_stage"`
Idempotency map[string]any `json:"idempotency"`
SideEffects map[string]any `json:"side_effects"`
}
payload, _ := json.Marshal(second.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode conflict payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.ConnectorStage != "blocked" {
t.Fatalf("conflict should be blocked: %s", payload)
}
if !strings.Contains(strings.ToLower(decoded.BlockedReason), "idempotency") {
t.Fatalf("blocked reason should explain idempotency conflict: %q", decoded.BlockedReason)
}
if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true {
t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
t.Fatalf("conflict should not have side effects: %#v", decoded.SideEffects)
}
if len(audit.events) != 2 || audit.events[1].Result != "blocked" {
t.Fatalf("audit events = %+v, want second blocked event", audit.events)
}
}
func TestFileSendMessageIdempotencyStoreDetectsDuplicateAndConflict(t *testing.T) {
store := fileSendMessageIdempotencyStore{path: t.TempDir() + "\\send-idempotency.jsonl"}
first := SendMessageIdempotencyRecord{
Tool: ToolNameSendMessage,
TargetRef: "contact:alice@imopenfire1-lanzhou",
ContentSHA256: sha256HexForSendMessageTest("first body"),
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-file-store"),
AuditRef: "send-message:first",
StartedAt: "2026-07-10T00:00:00Z",
}
firstDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
if err != nil {
t.Fatalf("reserve first: %v", err)
}
if firstDecision.Duplicate || firstDecision.Conflict {
t.Fatalf("first decision = %+v, want fresh", firstDecision)
}
duplicateDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
if err != nil {
t.Fatalf("reserve duplicate: %v", err)
}
if !duplicateDecision.Duplicate || duplicateDecision.Conflict || duplicateDecision.FirstAuditRef != first.AuditRef {
t.Fatalf("duplicate decision = %+v", duplicateDecision)
}
conflict := first
conflict.ContentSHA256 = sha256HexForSendMessageTest("changed body")
conflict.AuditRef = "send-message:conflict"
conflictDecision, err := store.ReserveSendMessageIdempotency(context.Background(), conflict)
if err != nil {
t.Fatalf("reserve conflict: %v", err)
}
if !conflictDecision.Conflict || conflictDecision.Duplicate {
t.Fatalf("conflict decision = %+v", conflictDecision)
}
if conflictDecision.FirstAuditRef != first.AuditRef {
t.Fatalf("conflict first audit ref = %q, want %q", conflictDecision.FirstAuditRef, first.AuditRef)
}
}
type fakeSendMessageAuditSink struct {
events []SendMessageAuditEvent
}
@@ -174,6 +342,34 @@ func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, eve
return nil
}
type fakeSendMessageIdempotencyStore struct {
records []SendMessageIdempotencyRecord
}
func newFakeSendMessageIdempotencyStore() *fakeSendMessageIdempotencyStore {
return &fakeSendMessageIdempotencyStore{}
}
func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
for _, existing := range f.records {
if existing.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
continue
}
decision := SendMessageIdempotencyDecision{
FirstAuditRef: existing.AuditRef,
FirstStartedAt: existing.StartedAt,
}
if existing.TargetRef == record.TargetRef && existing.ContentSHA256 == record.ContentSHA256 {
decision.Duplicate = true
return decision, nil
}
decision.Conflict = true
return decision, nil
}
f.records = append(f.records, record)
return SendMessageIdempotencyDecision{}, nil
}
func sha256HexForSendMessageTest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])

View File

@@ -103,6 +103,17 @@ func main() {
fail("server/env", err)
}
}
sendStateDir, err := os.MkdirTemp("", "isphere-send-state-*")
if err != nil {
fail("server/send_state", err)
}
defer os.RemoveAll(sendStateDir)
if err := os.Setenv("ISPHERE_SEND_AUDIT_PATH", filepath.Join(sendStateDir, "send-preview.jsonl")); err != nil {
fail("server/send_state", err)
}
if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-idempotency.jsonl")); err != nil {
fail("server/send_state", err)
}
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server, err := mcpserver.NewServerFromEnv()