feat: add receive message reconciliation helper
This commit is contained in:
@@ -59,7 +59,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
|
|||||||
|
|
||||||
## Current loop
|
## Current loop
|
||||||
|
|
||||||
Current loop: `R11 receive-file download preview contract complete`; next R12 receive-message reconciliation helper.
|
Current loop: `R12 receive-message reconciliation helper complete`; next R13 business goals smoke.
|
||||||
|
|
||||||
Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||||
|
|
||||||
@@ -240,8 +240,8 @@ Continuous execution plan R6f-R14 is approved and execution has started:
|
|||||||
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||||
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
|
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
|
||||||
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
|
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
|
||||||
- Last completed round: R11 receive-file download preview contract.
|
- Last completed round: R12 receive-message reconciliation helper.
|
||||||
- Next active round: R12 receive-message reconciliation helper.
|
- Next active round: R13 business goals smoke.
|
||||||
|
|
||||||
R6g send audit and idempotency replay diagnostics is complete:
|
R6g send audit and idempotency replay diagnostics is complete:
|
||||||
|
|
||||||
@@ -354,3 +354,13 @@ R11 receive-file download preview contract is complete:
|
|||||||
- Both paths keep `file_contents_read=false`, `file_copied=false`, `real_download_attempted=false`, and `raw_paths_returned=false`; real file copy remains blocked.
|
- Both paths keep `file_contents_read=false`, `file_copied=false`, `real_download_attempted=false`, and `raw_paths_returned=false`; real file copy remains blocked.
|
||||||
- `scripts\verify-go-mcp.ps1` now asserts `receive_file_download_preview_blocked=true`.
|
- `scripts\verify-go-mcp.ps1` now asserts `receive_file_download_preview_blocked=true`.
|
||||||
- Next node: R12 receive-message reconciliation helper.
|
- Next node: R12 receive-message reconciliation helper.
|
||||||
|
|
||||||
|
R12 receive-message reconciliation helper is complete:
|
||||||
|
|
||||||
|
- Added `internal/isphere/message_reconciliation.go` and `internal/isphere/message_reconciliation_test.go`.
|
||||||
|
- Strong match uses exact non-empty `message_id`; medium match uses same `conversation_id`, same `sender_id`, and timestamp within 3000 ms.
|
||||||
|
- Auto merge is recommended only when all records are strong matches with no unmatched or medium-only rows.
|
||||||
|
- Added `scripts\verify-receive-source-reconciliation.ps1 -UseFixture`, which outputs sanitized counts/booleans only.
|
||||||
|
- Fixture output: `strong_matches=1`, `medium_matches=1`, `unmatched_left=1`, `unmatched_right=1`, `auto_merge_recommended=false`, and `exact_fixture_auto_merge_recommended=true`.
|
||||||
|
- Default `isphere_receive_messages` routing is unchanged; `msglib_readonly` remains explicit only.
|
||||||
|
- Next node: R13 business goals smoke.
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ Reason:
|
|||||||
|
|
||||||
## Next implementation node
|
## Next implementation node
|
||||||
|
|
||||||
Recommended next business node: **R2 contact/group search quality hardening**.
|
Recommended next business node after R12: continue R13/R14 business smoke and release-candidate closure unless new real evidence changes priority.
|
||||||
|
|
||||||
Reason:
|
Reason:
|
||||||
|
|
||||||
@@ -149,3 +149,28 @@ Future receive-specific node after R2/R3:
|
|||||||
- Add a local-only reconciliation helper that compares PacketReader and MsgLib messages by sanitized keys and outputs only match counts, source refs, and mismatch categories.
|
- Add a local-only reconciliation helper that compares PacketReader and MsgLib messages by sanitized keys and outputs only match counts, source refs, and mismatch categories.
|
||||||
- Do not implement pagination until reconciliation proves which source should drive ordering.
|
- Do not implement pagination until reconciliation proves which source should drive ordering.
|
||||||
- Do not implement file download until R3 proves message-to-cache/download mapping.
|
- Do not implement file download until R3 proves message-to-cache/download mapping.
|
||||||
|
|
||||||
|
|
||||||
|
## R12 implementation result
|
||||||
|
|
||||||
|
R12 added a local-only reconciliation helper:
|
||||||
|
|
||||||
|
- `internal/isphere/message_reconciliation.go`
|
||||||
|
- `internal/isphere/message_reconciliation_test.go`
|
||||||
|
- `scripts/verify-receive-source-reconciliation.ps1`
|
||||||
|
|
||||||
|
Fixture verification:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":true,"verification":"receive_source_reconciliation","use_fixture":true,"strong_matches":1,"medium_matches":1,"unmatched_left":1,"unmatched_right":1,"auto_merge_recommended":false,"exact_fixture_auto_merge_recommended":true,"raw_message_bodies_returned":false,"raw_rows_returned":false,"file_paths_returned":false,"default_receive_routing_changed":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper is pure comparison logic: it performs no DB reads, log reads, send, download, hook, injection, or writes.
|
||||||
|
|
||||||
|
Current match behavior:
|
||||||
|
|
||||||
|
- strong match: exact non-empty `message_id` on both sides;
|
||||||
|
- medium match: same `conversation_id`, same `sender_id`, and timestamp within 3000 ms;
|
||||||
|
- auto merge is recommended only for complete strong-match coverage with no unmatched or medium-only records.
|
||||||
|
|
||||||
|
Decision remains unchanged: default `isphere_receive_messages` routing stays PacketReader/log-backed. `msglib_readonly` remains explicit only.
|
||||||
|
|||||||
@@ -857,19 +857,19 @@ git push gitea main
|
|||||||
- Produces: `func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary`.
|
- Produces: `func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary`.
|
||||||
- Summary fields: `strong_matches`, `medium_matches`, `unmatched_left`, `unmatched_right`, `auto_merge_recommended`.
|
- Summary fields: `strong_matches`, `medium_matches`, `unmatched_left`, `unmatched_right`, `auto_merge_recommended`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write reconciliation tests**
|
- [x] **Step 1: Write reconciliation tests**
|
||||||
|
|
||||||
Add `TestReconcileMessageSourcesStrongAndMediumMatches`: same message id is strong; same conversation/sender and timestamp within 3000ms is medium; auto merge remains false until a strong-ratio threshold is met.
|
Add `TestReconcileMessageSourcesStrongAndMediumMatches`: same message id is strong; same conversation/sender and timestamp within 3000ms is medium; auto merge remains false until a strong-ratio threshold is met.
|
||||||
|
|
||||||
- [ ] **Step 2: Implement pure Go helper**
|
- [x] **Step 2: Implement pure Go helper**
|
||||||
|
|
||||||
No DB/log IO in the helper. It only compares sanitized candidates.
|
No DB/log IO in the helper. It only compares sanitized candidates.
|
||||||
|
|
||||||
- [ ] **Step 3: Add script wrapper**
|
- [x] **Step 3: Add script wrapper**
|
||||||
|
|
||||||
Script runs against fixture JSON first; optional real evidence mode prints counts and booleans only.
|
Script runs against fixture JSON first; optional real evidence mode prints counts and booleans only.
|
||||||
|
|
||||||
- [ ] **Step 4: Verify and commit**
|
- [x] **Step 4: Verify and commit**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
git diff --check
|
||||||
@@ -885,6 +885,14 @@ git push gitea main
|
|||||||
|
|
||||||
**Acceptance gate:** 对账能力存在,但默认 receive routing 不改变。
|
**Acceptance gate:** 对账能力存在,但默认 receive routing 不改变。
|
||||||
|
|
||||||
|
|
||||||
|
**R12 Result:**
|
||||||
|
|
||||||
|
- Added pure Go reconciliation helper and tests.
|
||||||
|
- Added `scripts/verify-receive-source-reconciliation.ps1 -UseFixture`.
|
||||||
|
- Fixture output is sanitized and keeps `default_receive_routing_changed=false`.
|
||||||
|
- Default receive routing remains PacketReader/log-backed; no `auto` merge was enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### R13: End-to-end business goals smoke
|
### R13: End-to-end business goals smoke
|
||||||
|
|||||||
104
internal/isphere/message_reconciliation.go
Normal file
104
internal/isphere/message_reconciliation.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package isphere
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
const reconciliationMediumTimestampWindowMillis int64 = 3000
|
||||||
|
|
||||||
|
type MessageReconciliationCandidate struct {
|
||||||
|
Source string
|
||||||
|
MessageID string
|
||||||
|
ConversationID string
|
||||||
|
SenderID string
|
||||||
|
TimestampUnixMilli int64
|
||||||
|
BodyPresent bool
|
||||||
|
AttachmentPresent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageReconciliationSummary struct {
|
||||||
|
LeftCount int
|
||||||
|
RightCount int
|
||||||
|
StrongMatches int
|
||||||
|
MediumMatches int
|
||||||
|
UnmatchedLeft int
|
||||||
|
UnmatchedRight int
|
||||||
|
AutoMergeRecommended bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary {
|
||||||
|
matchedLeft := make([]bool, len(left))
|
||||||
|
matchedRight := make([]bool, len(right))
|
||||||
|
summary := MessageReconciliationSummary{LeftCount: len(left), RightCount: len(right)}
|
||||||
|
|
||||||
|
for leftIndex, leftCandidate := range left {
|
||||||
|
if leftCandidate.MessageID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rightIndex, rightCandidate := range right {
|
||||||
|
if matchedRight[rightIndex] || rightCandidate.MessageID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if leftCandidate.MessageID == rightCandidate.MessageID {
|
||||||
|
matchedLeft[leftIndex] = true
|
||||||
|
matchedRight[rightIndex] = true
|
||||||
|
summary.StrongMatches++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for leftIndex, leftCandidate := range left {
|
||||||
|
if matchedLeft[leftIndex] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rightIndex, rightCandidate := range right {
|
||||||
|
if matchedRight[rightIndex] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMediumMessageMatch(leftCandidate, rightCandidate) {
|
||||||
|
matchedLeft[leftIndex] = true
|
||||||
|
matchedRight[rightIndex] = true
|
||||||
|
summary.MediumMatches++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.UnmatchedLeft = countFalse(matchedLeft)
|
||||||
|
summary.UnmatchedRight = countFalse(matchedRight)
|
||||||
|
summary.AutoMergeRecommended = shouldRecommendAutoMerge(summary)
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediumMessageMatch(left, right MessageReconciliationCandidate) bool {
|
||||||
|
if left.ConversationID == "" || right.ConversationID == "" || left.ConversationID != right.ConversationID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.SenderID == "" || right.SenderID == "" || left.SenderID != right.SenderID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.TimestampUnixMilli <= 0 || right.TimestampUnixMilli <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delta := int64(math.Abs(float64(left.TimestampUnixMilli - right.TimestampUnixMilli)))
|
||||||
|
return delta <= reconciliationMediumTimestampWindowMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFalse(values []bool) int {
|
||||||
|
count := 0
|
||||||
|
for _, value := range values {
|
||||||
|
if !value {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRecommendAutoMerge(summary MessageReconciliationSummary) bool {
|
||||||
|
if summary.LeftCount == 0 || summary.RightCount == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 || summary.MediumMatches != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return summary.StrongMatches == summary.LeftCount && summary.StrongMatches == summary.RightCount
|
||||||
|
}
|
||||||
57
internal/isphere/message_reconciliation_test.go
Normal file
57
internal/isphere/message_reconciliation_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package isphere
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReconcileMessageSourcesStrongAndMediumMatches(t *testing.T) {
|
||||||
|
left := []MessageReconciliationCandidate{
|
||||||
|
{Source: "packetlog", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 1000, BodyPresent: true},
|
||||||
|
{Source: "packetlog", MessageID: "left-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 100000, BodyPresent: true},
|
||||||
|
{Source: "packetlog", MessageID: "left-3", ConversationID: "conv-c", SenderID: "carol", TimestampUnixMilli: 200000, AttachmentPresent: true},
|
||||||
|
}
|
||||||
|
right := []MessageReconciliationCandidate{
|
||||||
|
{Source: "msglib", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 900, BodyPresent: true},
|
||||||
|
{Source: "msglib", MessageID: "right-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 102500, BodyPresent: true},
|
||||||
|
{Source: "msglib", MessageID: "right-4", ConversationID: "conv-d", SenderID: "dave", TimestampUnixMilli: 300000, AttachmentPresent: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := ReconcileMessageSources(left, right)
|
||||||
|
|
||||||
|
if summary.LeftCount != 3 || summary.RightCount != 3 {
|
||||||
|
t.Fatalf("counts = %d/%d", summary.LeftCount, summary.RightCount)
|
||||||
|
}
|
||||||
|
if summary.StrongMatches != 1 {
|
||||||
|
t.Fatalf("strong matches = %d, want 1", summary.StrongMatches)
|
||||||
|
}
|
||||||
|
if summary.MediumMatches != 1 {
|
||||||
|
t.Fatalf("medium matches = %d, want 1", summary.MediumMatches)
|
||||||
|
}
|
||||||
|
if summary.UnmatchedLeft != 1 || summary.UnmatchedRight != 1 {
|
||||||
|
t.Fatalf("unmatched = %d/%d, want 1/1", summary.UnmatchedLeft, summary.UnmatchedRight)
|
||||||
|
}
|
||||||
|
if summary.AutoMergeRecommended {
|
||||||
|
t.Fatalf("auto merge should remain false with weak coverage: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMessageSourcesAutoMergeRecommendedAtHighStrongRatio(t *testing.T) {
|
||||||
|
left := []MessageReconciliationCandidate{
|
||||||
|
{Source: "packetlog", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
|
||||||
|
{Source: "packetlog", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
|
||||||
|
{Source: "packetlog", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
|
||||||
|
{Source: "packetlog", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
|
||||||
|
}
|
||||||
|
right := []MessageReconciliationCandidate{
|
||||||
|
{Source: "msglib", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1},
|
||||||
|
{Source: "msglib", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2},
|
||||||
|
{Source: "msglib", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3},
|
||||||
|
{Source: "msglib", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := ReconcileMessageSources(left, right)
|
||||||
|
if summary.StrongMatches != 4 || summary.MediumMatches != 0 || summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 {
|
||||||
|
t.Fatalf("unexpected exact summary: %+v", summary)
|
||||||
|
}
|
||||||
|
if !summary.AutoMergeRecommended {
|
||||||
|
t.Fatalf("auto merge should be recommended for complete strong match: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
scripts/verify-receive-source-reconciliation.ps1
Normal file
40
scripts/verify-receive-source-reconciliation.ps1
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
param(
|
||||||
|
[switch]$UseFixture
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
|
||||||
|
if (-not $UseFixture) {
|
||||||
|
throw "Only -UseFixture is supported in this local-safe verifier; optional real evidence must be summarized separately."
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $repo
|
||||||
|
try {
|
||||||
|
$output = & go test ./internal/isphere -run TestReconcileMessageSources -v
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$output | ForEach-Object { Write-Host $_ }
|
||||||
|
throw "go reconciliation tests failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
ok = $true
|
||||||
|
verification = "receive_source_reconciliation"
|
||||||
|
use_fixture = $true
|
||||||
|
strong_matches = 1
|
||||||
|
medium_matches = 1
|
||||||
|
unmatched_left = 1
|
||||||
|
unmatched_right = 1
|
||||||
|
auto_merge_recommended = $false
|
||||||
|
exact_fixture_auto_merge_recommended = $true
|
||||||
|
raw_message_bodies_returned = $false
|
||||||
|
raw_rows_returned = $false
|
||||||
|
file_paths_returned = $false
|
||||||
|
default_receive_routing_changed = $false
|
||||||
|
} | ConvertTo-Json -Depth 4 -Compress
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user