fix(member-work-sync): validate inbox nudge payload hash

This commit is contained in:
777genius 2026-05-14 01:20:39 +03:00
parent 3d207451cb
commit 75a3938f84
7 changed files with 110 additions and 2 deletions

View file

@ -11,7 +11,11 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
if (existing.some((message) => message.messageId === input.messageId)) {
const existingMessage = existing.find((message) => message.messageId === input.messageId);
if (existingMessage) {
if (existingMessage.workSyncPayloadHash !== input.payloadHash) {
return { inserted: false, messageId: input.messageId, conflict: true };
}
return { inserted: false, messageId: input.messageId };
}
@ -30,6 +34,7 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
workSyncIntent: input.payload.workSyncIntent,
workSyncIntentKey: input.payload.workSyncIntentKey,
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
workSyncPayloadHash: input.payloadHash,
});
return {

View file

@ -159,6 +159,8 @@ export class TeamInboxReader {
(id): id is string => typeof id === 'string' && id.length > 0
)
: undefined,
workSyncPayloadHash:
typeof row.workSyncPayloadHash === 'string' ? row.workSyncPayloadHash : undefined,
slashCommand:
row.slashCommand &&
typeof row.slashCommand === 'object' &&

View file

@ -77,6 +77,7 @@ export class TeamInboxWriter {
...(request.workSyncReviewRequestEventIds?.length
? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds }
: {}),
...(request.workSyncPayloadHash ? { workSyncPayloadHash: request.workSyncPayloadHash } : {}),
...(request.slashCommand && { slashCommand: request.slashCommand }),
...(request.commandOutput && { commandOutput: request.commandOutput }),
};

View file

@ -670,6 +670,8 @@ export interface InboxMessage {
workSyncIntentKey?: string;
/** Concrete review_requested event IDs covered by this nudge. */
workSyncReviewRequestEventIds?: string[];
/** Durable hash for idempotent hidden member-work-sync automation rows. */
workSyncPayloadHash?: string;
/** Structured slash-command metadata for sent command rows. */
slashCommand?: SlashCommandMeta;
/** Structured command-output metadata for session-derived result rows. */
@ -717,6 +719,7 @@ export interface SendMessageRequest {
workSyncIntent?: InboxMessage['workSyncIntent'];
workSyncIntentKey?: string;
workSyncReviewRequestEventIds?: string[];
workSyncPayloadHash?: string;
slashCommand?: SlashCommandMeta;
commandOutput?: CommandOutputMeta;
}

View file

@ -31,7 +31,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
it('returns inserted=false when the inbox already contains the stable messageId', async () => {
const input = makeInput();
const inboxReader = {
getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]),
getMessagesFor: vi.fn(async () => [
{ messageId: input.messageId, workSyncPayloadHash: input.payloadHash },
]),
};
const inboxWriter = {
sendMessage: vi.fn(),
@ -47,6 +49,52 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
});
it('fails closed when the existing stable messageId has a different payload hash', async () => {
const input = makeInput();
const inboxReader = {
getMessagesFor: vi.fn(async () => [
{ messageId: input.messageId, workSyncPayloadHash: 'different-payload-hash' },
]),
};
const inboxWriter = {
sendMessage: vi.fn(),
};
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
inserted: false,
messageId: input.messageId,
conflict: true,
});
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
});
it('treats legacy work-sync rows without payload hash as conflicts', async () => {
const input = makeInput();
const inboxReader = {
getMessagesFor: vi.fn(async () => [
{
messageId: input.messageId,
messageKind: 'member_work_sync_nudge',
workSyncIntent: input.payload.workSyncIntent,
},
]),
};
const inboxWriter = {
sendMessage: vi.fn(),
};
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
inserted: false,
messageId: input.messageId,
conflict: true,
});
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
});
it('writes a system notification inbox message for a new nudge', async () => {
const input = makeInput();
const inboxReader = {
@ -77,6 +125,7 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
workSyncIntent: 'agenda_sync',
workSyncIntentKey: undefined,
workSyncReviewRequestEventIds: undefined,
workSyncPayloadHash: input.payloadHash,
});
});

View file

@ -177,6 +177,35 @@ describe('TeamInboxReader', () => {
});
});
it('preserves member-work-sync payload hash without changing visible message fields', async () => {
hoisted.files.set(
'/mock/teams/my-team/inboxes/alice.json',
JSON.stringify([
{
from: 'system',
to: 'alice',
text: 'Please reconcile current work.',
timestamp: '2026-01-01T02:30:00.000Z',
read: false,
messageId: 'member-work-sync:my-team:alice:agenda',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
workSyncPayloadHash: 'sha256:work-sync',
},
])
);
const messages = await reader.getMessagesFor('my-team', 'alice');
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
messageId: 'member-work-sync:my-team:alice:agenda',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
workSyncPayloadHash: 'sha256:work-sync',
});
});
it('preserves agent error semantic kind from the team lead inbox', async () => {
hoisted.files.set(
'/mock/teams/my-team/inboxes/team-lead.json',

View file

@ -157,6 +157,25 @@ describe('TeamInboxWriter', () => {
expect(persisted[0].source).toBe('system_notification');
});
it('persists member-work-sync payload hash when provided', async () => {
await writer.sendMessage('my-team', {
member: 'alice',
text: 'sync your work state',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
workSyncPayloadHash: 'sha256:work-sync',
});
const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record<string, unknown>[];
expect(persisted).toHaveLength(1);
expect(persisted[0]).toMatchObject({
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
workSyncPayloadHash: 'sha256:work-sync',
});
});
it('preserves provided message identity fields for dedup across live and persisted rows', async () => {
const result = await writer.sendMessage('my-team', {
member: 'alice',