Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ Adopts the covenant scale (LBTAS, v2 floor) and carries its own record instance.

The reference economy. Its operator/federation model is the named reference for the substrate's frontend-as-operator design target.

The application message API supports optional retry receipts, scoped to the
authenticated caller and conversation. These are private operational metadata,
not sealed dialog or commons entries; they store hashes and message references,
not narrative. This does not promote any in-review binding above. See
[`backend/docs/retry-safe-messages.md`](backend/docs/retry-safe-messages.md) for
the guard, deployment requirements, crash limits, and executable checks.

### Browser garden planner

The Next.js `/garden-planner` page is a member-local planning aid, not a ledger
Expand Down
5 changes: 5 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ Endpoints (used by the Chat UI):
- `token`: `{ id, token }`
- `message`: `{ message }`

Message sending supports an optional `Idempotency-Key` header so a lost response
can be retried without another message, upload, broadcast, or AI invocation.
See [retry-safe messages](docs/retry-safe-messages.md) for the client contract,
required receipt table/permissions, failure handling, and integration checks.

Server emitters (global):
- `emitToken(conversationId, id, token)`
- `emitMessage(conversationId, message)`
Expand Down
393 changes: 393 additions & 0 deletions backend/__tests__/message_retries.test.js

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions backend/controllers/communication_controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Message = require('../models/message');
const agrinetResponder = require('../services/agrinetResponder');
const openAIResponder = require('../services/openAIResponder');
const retrySafeMessage = require('../services/retrySafeMessage');

function broadcastMessage(conversationId, msg, options = {}) {
if (!msg) return;
Expand Down Expand Up @@ -57,7 +58,7 @@ function shouldAutoRespond({ from, type }) {
return ['user', 'client', 'farmer'].includes(normalized);
}

exports.sendMessage = async (req, res) => {
async function deliverMessage(req) {
const { conversationId } = req.params;
const { from, to, content, type, file } = req.body;

Expand Down Expand Up @@ -110,11 +111,19 @@ exports.sendMessage = async (req, res) => {
responsePayload.reply = formattedReply;
}

res.status(201).json(responsePayload);
return responsePayload;
}

exports.sendMessage = async (req, res) => {
if (req.headers?.['idempotency-key'] === undefined) {
return res.status(201).json(await deliverMessage(req));
}
const result = await retrySafeMessage.send(req, () => deliverMessage(req));
return res.status(result.status).json(result.body);
};

exports.listMessages = async (req, res) => {
const { conversationId } = req.params;
const msgs = await Message.listMessages(conversationId);
res.json(msgs);
};
};
154 changes: 154 additions & 0 deletions backend/docs/retry-safe-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Retry-safe message sending

## Why this exists

A client can lose its connection after the backend has saved a message but
before it receives the response. Sending the same request again previously
created another message, repeated its broadcasts, and could generate another
AI reply. A unique message ID generated by the server cannot identify that retry.

`POST /messages/:conversationId` now accepts an optional `Idempotency-Key` header.
The client creates a fresh key for each deliberate send and keeps that same key
and payload for every retry of that send. A UUID is suitable. This backend change
does not add automatic retries or change any frontend; clients must opt in.

```http
POST /messages/conversation-123
Authorization: Bearer <token>
Idempotency-Key: 573d9ba2-9d86-4da2-b752-12b085b838d1
Content-Type: application/json

{"from":"user","to":"ai","content":"When should I plant tomatoes?","type":"text"}
```

The existing `{message, ai?, reply?}` success shape and `201` status are preserved.
A completed retry returns the same persisted message and reply, including their
IDs and timestamps, without uploads, broadcasts, token streaming, or AI calls.
Clients that consume both HTTP and SSE should reconcile using message IDs.

## Contract

Keys contain 1–128 printable ASCII characters, without spaces. Scope is the
conversation plus the authenticated JWT `userId`, or the accepted API credential.
It is never the caller-supplied `from` label. All clients sharing an API credential
share its retry namespace; key uniqueness is their responsibility. JWT refreshes
keep the same namespace when `userId` is unchanged; rotating an API key changes it.
The middleware's existing authentication and conversation-access behavior is
unchanged. Retry scoping does not add participant authorization to conversations.

The payload fingerprint covers `from`, `to`, `content`, `type` (default `text`),
and an attachment's name, MIME type, and base64 data. JSON property order does not
matter. Fields ignored by the existing send path do not distinguish requests.

| Status | Code | Meaning and client action |
| --- | --- | --- |
| `201` | — | Original send completed, or its completed response was reconstructed. |
| `400` | `invalid_idempotency_key` | Fix the header before sending. |
| `400` | `idempotency_identity_required` | Keyed sends require a stable authenticated user ID or API key. |
| `409` | `idempotency_key_conflict` | Same key and scope, different payload. Do not overwrite the original send. |
| `409` | `message_send_pending` | Another request is running, or a worker stopped before recording the outcome. Retry the same key later; inspect the conversation if it remains pending. |
| `410` | `message_send_removed` | The original message or reply was deleted. A retry will not recreate it. |
| `500` | `message_send_failed` | Delivery failed after claiming the key. This failure is retained; repeating the key does not rerun side effects. Inspect the conversation. |
| `503` | `message_send_unavailable` | Receipt storage could not acknowledge a claim. This request did not start delivery. Retry only with the same key; the write may have committed despite a lost acknowledgment. |
| `503` | `message_send_uncertain` | Delivery ran, but recording its outcome failed or timed out. Retry only with the same key and inspect the conversation; do not automatically use a new key. |

Authentication errors retain their existing responses. A database read failure
while reconstructing a completed response uses the existing generic `500` path;
the receipt remains completed, so retrying the same key does not rerun delivery.

## How the guard works, and its limits

A conditional DynamoDB put claims a receipt before any message or upload is
written. Competing workers read that receipt consistently. Only the successful
claimant invokes the existing delivery function. A conditional update records
completion or terminal failure; completed success receipts point to persisted
messages, not copies of their text.

This is not an exactly-once transaction across DynamoDB, local files, SSE, and
an external AI provider. A crash after claiming the key can leave a permanent
pending receipt, including when no message was written. There is deliberately no
lease takeover or automatic reset: replaying an uncertain operation could create
the duplicates this feature prevents. Pending sends require operator inspection
of stored messages and provider activity. No recovery endpoint is added here.
The existing AI responder's fallback behavior is unchanged.

Receipts have no automatic expiry. Deleting or expiring them, dropping their
table, or restoring it out of step with Messages removes protection against old
retries. Retain and back up both tables together. Do not automatically clear a
receipt to unblock a client or instruct the client to generate a new key without
first reconciling any messages, uploads, and external work already produced.

## Provisioning

Before enabling keyed sends in a client, create a separate table. No existing
Messages key schema is changed. `MESSAGE_SEND_TABLE_NAME` defaults to `MessageSends`.

```sh
aws dynamodb create-table \
--table-name MessageSends \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
```

Use the backend's configured AWS region and environment; provision through the
deployment's normal review process. Grant the backend `GetItem`, `PutItem`, and
`UpdateItem` on this table, and `GetItem` on Messages for response reconstruction.
The receipt table requires no GSI or TTL. Missing table/permission errors stop
keyed sends before delivery; requests without a header do not access this table.

Receipts contain only an opaque scoped-key hash, a payload hash, processing state,
and outcome references/status. They contain no raw keys, credentials, account
profiles, attachment bytes, or chat narrative. Keep this application metadata
private to the platform, alongside its message store. Conversation deletion still
erases its message content; retained receipts prevent resurrection on retry.

API-credential receipt IDs use asynchronous scrypt (`N=16384`, `r=8`, `p=5`,
32-byte output). The salt is the hex SHA-256 of the JSON array
`["message-send-api-key-v2", conversationId, idempotencyKey]`; credential bytes
never enter a fast hash. This makes offline credential guesses against leaked
receipts expensive. Each active derivation uses roughly 16 MiB; concurrent work
runs through Node's worker pool. JWT receipt IDs and payload fingerprints retain
SHA-256 because they do not include authentication secrets.

This replaces the undeployed API-key ID derivation from the initial PR revision.
Disposable test receipts from that revision cannot replay under this derivation.
Do not mix the two revisions' API-key workers or reuse old test receipts as
production retry protection.

## NTARI boundaries

This extends the existing application message API. It does not edit a sealed
dialog, append narrative to the commons, change assessment distributions, or
alter escrow, balances, or governance. Existing `CONFORMANCE.md` in-review labels
remain in review. The new receipt state is operational application metadata, not
the append-only JFA exchange record, and is not federated or exported here.

References reviewed:

- [Slack: use Janus and the bylaws to govern work](https://ntari.slack.com/archives/C0AENHR5KG8/p1791982812690669)
- [Slack: local agriculture purpose and DCO on commits](https://ntari.slack.com/archives/C0AENHR5KG8/p1792416355639889)
- [Confluence: Fruitful-UI purpose](https://ntari.atlassian.net/wiki/spaces/Fruitful/pages/44761089)
- [Confluence: DynamoDB transition guidance](https://ntari.atlassian.net/wiki/spaces/Agrinet/pages/74416160)

The transition guidance describes possible message keys; the existing code's
`{conversationId, id}` message key remains authoritative for this implementation.

## Verification

`npm test` includes HTTP tests using the real auth, controller, persistence, and
streaming code, with only DynamoDB and the external AI provider replaced.

The separate integration check uses the real SDK and DynamoDB Local, two HTTP
worker processes, restart/replay, and process death during an AI call. It creates
and deletes uniquely named tables, refuses non-loopback endpoints, and uses a
local AI fixture without contacting an external provider:

```sh
docker run --rm -d --name agrinet-retry-check \
-p 127.0.0.1:18001:8000 amazon/dynamodb-local:latest \
-jar DynamoDBLocal.jar -inMemory -sharedDb
DYNAMODB_TEST_ENDPOINT=http://127.0.0.1:18001 node scripts/check-message-retries.js
DYNAMODB_TEST_ENDPOINT=http://127.0.0.1:18001 node scripts/check-message-retries.js --api-key
docker stop agrinet-retry-check
```
9 changes: 8 additions & 1 deletion backend/models/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ async function listMessages(conversationId) {
return res.Items || [];
}

async function getMessage(conversationId, id) {
const { Item } = await docClient.get({
TableName: MESSAGE_TABLE_NAME, Key: { conversationId, id }, ConsistentRead: true,
}).promise();
return Item || null;
}

async function removeByConversation(conversationId) {
const messages = await listMessages(conversationId);
if (!messages.length) return;
Expand All @@ -66,4 +73,4 @@ function setDocClient(client) {
docClient = client;
}

module.exports = { sendMessage, listMessages, removeByConversation, setDocClient };
module.exports = { sendMessage, listMessages, getMessage, removeByConversation, setDocClient };
38 changes: 38 additions & 0 deletions backend/models/messageSend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const docClient = require('../lib/dynamodbClient');

const MESSAGE_SEND_TABLE_NAME = process.env.MESSAGE_SEND_TABLE_NAME || 'MessageSends';

async function begin(id, fingerprint) {
try {
await docClient.put({
TableName: MESSAGE_SEND_TABLE_NAME,
Item: { id, fingerprint, state: 'processing' },
ConditionExpression: 'attribute_not_exists(id)',
}).promise();
return { claimed: true };
} catch (error) {
if (error.code !== 'ConditionalCheckFailedException') throw error;
const { Item } = await docClient.get({
TableName: MESSAGE_SEND_TABLE_NAME,
Key: { id },
ConsistentRead: true,
}).promise();
if (!Item) throw new Error('Message send receipt unavailable');
return { claimed: false, receipt: Item };
}
}

async function complete(id, fingerprint, outcome) {
await docClient.update({
TableName: MESSAGE_SEND_TABLE_NAME,
Key: { id },
UpdateExpression: 'SET #state = :complete, outcome = :outcome',
ConditionExpression: '#state = :processing AND fingerprint = :fingerprint',
ExpressionAttributeNames: { '#state': 'state' },
ExpressionAttributeValues: {
':complete': 'complete', ':processing': 'processing', ':fingerprint': fingerprint, ':outcome': outcome,
},
}).promise();
}

module.exports = { begin, complete };
Loading
Loading