From e1fc3c6010da772a2252c295809d004946026616 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 16:32:52 -0300
Subject: [PATCH 01/13] feat(m5-05): configure per-asset message replies
---
apps/api/package.json | 1 +
.../automation-config.controller.ts | 118 +++++++
.../automation-config.service.ts | 111 +++++++
.../automation-config.tokens.ts | 1 +
.../automation-config.types.ts | 64 ++++
.../mysql-automation-config.repository.ts | 153 +++++++++
.../database/migrations/automation-config.ts | 144 +++++++++
.../api/src/database/migrations/migrations.ts | 3 +
apps/api/src/identity/identity.service.ts | 1 +
apps/api/src/identity/identity.types.ts | 1 +
.../api/src/meta-assets/meta-assets.module.ts | 20 +-
...5-05-automation-config.integration.spec.ts | 125 ++++++++
apps/api/test/m5-05-automation-config.spec.ts | 296 ++++++++++++++++++
apps/web/package.json | 1 +
.../portal/automation-config.service.spec.ts | 69 ++++
.../core/portal/automation-config.service.ts | 49 +++
.../asset-automation-config.component.html | 41 +++
.../asset-automation-config.component.scss | 19 ++
.../asset-automation-config.component.spec.ts | 55 ++++
.../asset-automation-config.component.ts | 149 +++++++++
.../features/portal/asset-detail.component.ts | 5 +-
.../connection-record-detail.component.html | 2 +
.../connection-record-detail.component.scss | 3 +
...connection-record-detail.component.spec.ts | 2 +
.../connection-record-detail.component.ts | 3 +
docs/delivery/M5/M5.05.md | 59 ++++
.../contracts/events/outbox-event.schema.json | 16 +-
docs/meta-v2/contracts/openapi.yaml | 120 +++++--
.../contracts/events/outbox-event.schema.json | 16 +-
packages/contracts/openapi.yaml | 147 +++++++--
packages/contracts/src/generated/openapi.d.ts | 51 ++-
tools/m5-05/run-focused-api-test.mjs | 66 ++++
tools/m5-05/run-focused-web-tests.mjs | 59 ++++
33 files changed, 1904 insertions(+), 66 deletions(-)
create mode 100644 apps/api/src/automation-config/automation-config.controller.ts
create mode 100644 apps/api/src/automation-config/automation-config.service.ts
create mode 100644 apps/api/src/automation-config/automation-config.tokens.ts
create mode 100644 apps/api/src/automation-config/automation-config.types.ts
create mode 100644 apps/api/src/automation-config/mysql-automation-config.repository.ts
create mode 100644 apps/api/src/database/migrations/automation-config.ts
create mode 100644 apps/api/test/m5-05-automation-config.integration.spec.ts
create mode 100644 apps/api/test/m5-05-automation-config.spec.ts
create mode 100644 apps/web/src/app/core/portal/automation-config.service.spec.ts
create mode 100644 apps/web/src/app/core/portal/automation-config.service.ts
create mode 100644 apps/web/src/app/features/portal/asset-automation-config.component.html
create mode 100644 apps/web/src/app/features/portal/asset-automation-config.component.scss
create mode 100644 apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
create mode 100644 apps/web/src/app/features/portal/asset-automation-config.component.ts
create mode 100644 docs/delivery/M5/M5.05.md
create mode 100644 tools/m5-05/run-focused-api-test.mjs
create mode 100644 tools/m5-05/run-focused-web-tests.mjs
diff --git a/apps/api/package.json b/apps/api/package.json
index 89248481..9285844e 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -17,6 +17,7 @@
"test:m5-02": "node ../../tools/m5-02/run-focused-api-test.mjs",
"test:m5-03": "node ../../tools/m5-03/run-focused-api-test.mjs",
"test:m5-04": "node ../../tools/m5-04/run-focused-api-test.mjs",
+ "test:m5-05": "node ../../tools/m5-05/run-focused-api-test.mjs",
"test:m4-01": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/m4-01-meta-app-config.spec.js",
"test:m3-09": "node ../../tools/m3-09/run-focused-test.mjs",
"test:m3-08": "node ../../tools/m3-08/run-focused-test.mjs",
diff --git a/apps/api/src/automation-config/automation-config.controller.ts b/apps/api/src/automation-config/automation-config.controller.ts
new file mode 100644
index 00000000..207a92f0
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.controller.ts
@@ -0,0 +1,118 @@
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Get,
+ Headers,
+ HttpException,
+ HttpStatus,
+ Param,
+ Patch,
+ Req,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
+import type { Response } from 'express';
+import type { components } from '@ca-meta/contracts';
+import { PublicRoute } from '../common/security/public-route.decorator.js';
+import { PortalSessionGuard, type PortalRequest } from '../identity/portal-session.guard.js';
+import { PORTAL_CONTEXT_COOKIE, readPortalCookie } from '../identity/portal-session.service.js';
+import { AutomationConfigService, automationConfigEtag } from './automation-config.service.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ UnsupportedAutomationConfigFieldError,
+} from './automation-config.types.js';
+
+const CONTROL_SAFE_TEXT = /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$/;
+
+class AutomationConfigPatchDto {
+ @IsInt()
+ @Min(0)
+ @Max(Number.MAX_SAFE_INTEGER)
+ version!: number;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ privateWelcomeMessage?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ commentPublicReply?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(4096)
+ @Matches(CONTROL_SAFE_TEXT)
+ commentPrivateReply?: string;
+}
+
+function contextRef(request: PortalRequest, headerContext?: string): string {
+ const ref = headerContext ?? readPortalCookie(request.headers.cookie, PORTAL_CONTEXT_COOKIE);
+ if (!ref || !/^[A-Za-z0-9._~-]{16,512}$/.test(ref)) throw new BadRequestException();
+ return ref;
+}
+
+function requestId(request: PortalRequest): string { return request.requestId ?? 'unknown'; }
+
+function parseIfMatch(value: string | undefined): number {
+ const match = value ? /^"automation-v(0|[1-9]\d*)"$/.exec(value) : null;
+ const version = match ? Number(match[1]) : NaN;
+ if (!Number.isSafeInteger(version) || version < 0) throw new BadRequestException();
+ return version;
+}
+
+function mapAutomationError(error: unknown): never {
+ if (error instanceof AutomationConfigNotFoundError) throw new HttpException('Resource not available.', HttpStatus.NOT_FOUND);
+ if (error instanceof AutomationConfigVersionConflictError) throw new HttpException('Configuration changed. Reload before saving.', HttpStatus.PRECONDITION_FAILED);
+ if (error instanceof AutomationConfigValidationError || error instanceof UnsupportedAutomationConfigFieldError) {
+ throw new HttpException('Check the supported fields and message text.', HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+ throw error;
+}
+
+@Controller()
+export class AutomationConfigController {
+ constructor(private readonly config: AutomationConfigService) {}
+
+ @Get('assets/:assetId/automation-config')
+ @PublicRoute()
+ @UseGuards(PortalSessionGuard)
+ async read(
+ @Param('assetId') assetId: string,
+ @Req() request: PortalRequest,
+ @Headers('x-tenant-context') headerContext?: string,
+ @Res({ passthrough: true }) response?: Response,
+ ): Promise {
+ try {
+ const result = await this.config.read(request.portalSession, contextRef(request, headerContext), assetId, requestId(request));
+ response?.setHeader('ETag', automationConfigEtag(result.version));
+ return result;
+ } catch (error) { return mapAutomationError(error); }
+ }
+
+ @Patch('assets/:assetId/automation-config')
+ @PublicRoute()
+ @UseGuards(PortalSessionGuard)
+ async update(
+ @Param('assetId') assetId: string,
+ @Body() body: AutomationConfigPatchDto,
+ @Req() request: PortalRequest,
+ @Headers('x-tenant-context') headerContext?: string,
+ @Headers('if-match') ifMatch?: string,
+ @Res({ passthrough: true }) response?: Response,
+ ): Promise {
+ try {
+ const result = await this.config.update(request.portalSession, contextRef(request, headerContext), assetId,
+ requestId(request), parseIfMatch(ifMatch), body);
+ response?.setHeader('ETag', automationConfigEtag(result.version));
+ return result;
+ } catch (error) { return mapAutomationError(error); }
+ }
+}
diff --git a/apps/api/src/automation-config/automation-config.service.ts b/apps/api/src/automation-config/automation-config.service.ts
new file mode 100644
index 00000000..95b882bf
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.service.ts
@@ -0,0 +1,111 @@
+import type { components } from '@ca-meta/contracts';
+import { IdentityDeniedError } from '../identity/identity.errors.js';
+import { isVerifiedPortalSession, type VerifiedPortalSession } from '../identity/identity.trust.js';
+import { TenantAuthorizationService, TenantContextService } from '../identity/identity.service.js';
+import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js';
+import type { MetaAssetsRepository } from '../meta-assets/mysql-meta-assets.repository.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ UnsupportedAutomationConfigFieldError,
+ automationConfigAssetKind,
+ automationConfigFieldsForAssetKind,
+ type AutomationConfigPatch,
+ type AutomationConfigRepository,
+} from './automation-config.types.js';
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const MAX_MESSAGE_LENGTH = 4096;
+const DISALLOWED_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
+
+function isEditor(roles: readonly string[]): boolean {
+ return roles.some((role) => role === 'owner' || role === 'admin' || role === 'operator');
+}
+
+function validatePatch(patch: AutomationConfigPatch, supportedFields: readonly string[]): void {
+ if (!Number.isSafeInteger(patch.version) || patch.version < 0) throw new AutomationConfigVersionConflictError();
+ const fields = ['privateWelcomeMessage', 'commentPublicReply', 'commentPrivateReply'] as const;
+ const changed = fields.filter((field) => Object.prototype.hasOwnProperty.call(patch, field));
+ if (changed.length === 0) throw new AutomationConfigValidationError();
+ for (const field of changed) {
+ if (!supportedFields.includes(field)) throw new AutomationConfigValidationError();
+ const value = patch[field];
+ if (typeof value !== 'string' || value.length > MAX_MESSAGE_LENGTH || DISALLOWED_CONTROLS.test(value)) {
+ throw new AutomationConfigValidationError();
+ }
+ }
+}
+
+export function automationConfigEtag(version: number): string { return `"automation-v${version}"`; }
+
+export class AutomationConfigService {
+ constructor(
+ private readonly repository: AutomationConfigRepository,
+ private readonly assets: MetaAssetsRepository,
+ private readonly contexts: TenantContextService,
+ private readonly authorization: TenantAuthorizationService,
+ private readonly environment: MetaEnvironment,
+ ) {}
+
+ async read(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string): Promise {
+ const scope = await this.scope(session, contextRef);
+ await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId);
+ const asset = await this.findAsset(scope.tenantId, assetId);
+ const assetKind = automationConfigAssetKind(asset.kind);
+ if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
+ const state = await this.repository.read({ tenantId: scope.tenantId, assetId: asset.assetRef });
+ return this.serialize(asset.assetRef, assetKind, state, isEditor(scope.roles), requestId);
+ }
+
+ async update(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string,
+ ifMatchVersion: number, patch: AutomationConfigPatch): Promise {
+ const scope = await this.scope(session, contextRef);
+ await this.authorization.authorize(scope, 'MANAGE_AUTOMATION_CONFIG', scope.tenantId);
+ const asset = await this.findAsset(scope.tenantId, assetId);
+ const assetKind = automationConfigAssetKind(asset.kind);
+ if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
+ const supportedFields = automationConfigFieldsForAssetKind(assetKind);
+ ifMatchVersion = Number.isSafeInteger(ifMatchVersion) && ifMatchVersion >= 0 ? ifMatchVersion : -1;
+ if (ifMatchVersion !== patch.version) throw new AutomationConfigVersionConflictError();
+ validatePatch(patch, supportedFields);
+ const state = await this.repository.update({
+ tenantId: scope.tenantId,
+ assetId: asset.assetRef,
+ actorId: scope.principalId,
+ requestId,
+ patch,
+ });
+ return this.serialize(asset.assetRef, assetKind, state, true, requestId);
+ }
+
+ private async scope(session: VerifiedPortalSession, contextRef: string) {
+ if (!isVerifiedPortalSession(session)) throw new IdentityDeniedError();
+ return this.contexts.resolveCurrent(session, contextRef);
+ }
+
+ private async findAsset(tenantId: string, assetId: string) {
+ if (!UUID.test(assetId)) throw new AutomationConfigNotFoundError();
+ const asset = await this.assets.findAssetCatalog({ tenantId, environment: this.environment, assetRef: assetId });
+ if (!asset) throw new AutomationConfigNotFoundError();
+ return asset;
+ }
+
+ private serialize(assetId: string, assetKind: components['schemas']['AutomationConfigAssetKind'],
+ state: Awaited>, canEdit: boolean, requestId: string): components['schemas']['AutomationConfig'] {
+ return Object.freeze({
+ assetId,
+ assetKind,
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: automationConfigFieldsForAssetKind(assetKind),
+ privateWelcomeMessage: state.privateWelcomeMessage,
+ commentPublicReply: state.commentPublicReply,
+ commentPrivateReply: state.commentPrivateReply,
+ capabilities: Object.freeze({ canEdit, executable: false }),
+ version: state.version,
+ updatedAt: state.updatedAt,
+ projectionState: state.projectionState,
+ requestId,
+ });
+ }
+}
diff --git a/apps/api/src/automation-config/automation-config.tokens.ts b/apps/api/src/automation-config/automation-config.tokens.ts
new file mode 100644
index 00000000..cd2dd7cd
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.tokens.ts
@@ -0,0 +1 @@
+export const AUTOMATION_CONFIG_REPOSITORY = Symbol('AUTOMATION_CONFIG_REPOSITORY');
diff --git a/apps/api/src/automation-config/automation-config.types.ts b/apps/api/src/automation-config/automation-config.types.ts
new file mode 100644
index 00000000..1325acc2
--- /dev/null
+++ b/apps/api/src/automation-config/automation-config.types.ts
@@ -0,0 +1,64 @@
+import type { components } from '@ca-meta/contracts';
+
+export type AutomationConfigField = components['schemas']['AutomationConfigField'];
+export type AutomationConfigAssetKind = components['schemas']['AutomationConfigAssetKind'];
+export type AutomationConfigProjectionState = components['schemas']['AutomationConfigProjectionState'];
+
+export interface AutomationConfigState {
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly version: number;
+ readonly updatedAt: string | null;
+ readonly projectionState: AutomationConfigProjectionState;
+}
+
+export interface AutomationConfigPatch {
+ readonly version: number;
+ readonly privateWelcomeMessage?: string;
+ readonly commentPublicReply?: string;
+ readonly commentPrivateReply?: string;
+}
+
+export interface AutomationConfigRepository {
+ read(input: { readonly tenantId: string; readonly assetId: string }): Promise;
+ update(input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise;
+}
+
+export const AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND: Readonly> = Object.freeze({
+ WHATSAPP_PHONE_NUMBER: Object.freeze(['privateWelcomeMessage'] as const),
+ FACEBOOK_PAGE: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+ INSTAGRAM_BUSINESS_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+ INSTAGRAM_CREATOR_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
+});
+
+export function automationConfigAssetKind(kind: string): AutomationConfigAssetKind | undefined {
+ return Object.prototype.hasOwnProperty.call(AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND, kind)
+ ? kind as AutomationConfigAssetKind : undefined;
+}
+
+export function automationConfigFieldsForAssetKind(kind: AutomationConfigAssetKind): readonly AutomationConfigField[] {
+ return AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND[kind];
+}
+
+export class AutomationConfigNotFoundError extends Error {
+ constructor() { super('Automation configuration asset is unavailable.'); this.name = 'AutomationConfigNotFoundError'; }
+}
+
+export class UnsupportedAutomationConfigFieldError extends Error {
+ constructor() { super('The requested field is not available for this asset.'); this.name = 'UnsupportedAutomationConfigFieldError'; }
+}
+
+export class AutomationConfigValidationError extends Error {
+ constructor() { super('Automation configuration update is invalid.'); this.name = 'AutomationConfigValidationError'; }
+}
+
+export class AutomationConfigVersionConflictError extends Error {
+ constructor() { super('Automation configuration version changed.'); this.name = 'AutomationConfigVersionConflictError'; }
+}
diff --git a/apps/api/src/automation-config/mysql-automation-config.repository.ts b/apps/api/src/automation-config/mysql-automation-config.repository.ts
new file mode 100644
index 00000000..3bf732e8
--- /dev/null
+++ b/apps/api/src/automation-config/mysql-automation-config.repository.ts
@@ -0,0 +1,153 @@
+import { createHash, randomUUID } from 'node:crypto';
+import type { EntityManager } from 'typeorm';
+import type { DurableWorkDatabase } from '../jobs/durable-work-database.js';
+import type { OutboxService } from '../outbox/outbox-service.js';
+import { AutomationConfigVersionConflictError, type AutomationConfigPatch, type AutomationConfigRepository, type AutomationConfigState } from './automation-config.types.js';
+
+interface AutomationConfigRow {
+ private_welcome_message: string | null;
+ comment_public_reply: string | null;
+ comment_private_reply: string | null;
+ version: number | string;
+ updated_at: string | Date | null;
+ projection_state: AutomationConfigState['projectionState'];
+}
+
+const EMPTY_CONFIG: AutomationConfigState = Object.freeze({
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+});
+
+function isRecordAffected(result: unknown, expected: number): boolean {
+ const header = Array.isArray(result) ? result[0] : result;
+ return typeof header === 'object' && header !== null && 'affectedRows' in header &&
+ Number((header as { affectedRows?: number }).affectedRows ?? 0) === expected;
+}
+
+function sqlTimestamp(value: string): string {
+ return `${value.slice(0, 19).replace('T', ' ')}.${value.slice(20, 23)}000`;
+}
+
+function formatTimestamp(value: string | Date | null): string | null {
+ if (value === null) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
+}
+
+function fromRow(row: AutomationConfigRow | undefined): AutomationConfigState {
+ if (!row) return EMPTY_CONFIG;
+ const version = Number(row.version);
+ if (!Number.isSafeInteger(version) || version < 0) throw new Error('Automation configuration version is outside the supported range.');
+ return Object.freeze({
+ privateWelcomeMessage: row.private_welcome_message,
+ commentPublicReply: row.comment_public_reply,
+ commentPrivateReply: row.comment_private_reply,
+ version,
+ updatedAt: formatTimestamp(row.updated_at),
+ projectionState: row.projection_state,
+ });
+}
+
+function actorHash(actorId: string): string {
+ return createHash('sha256').update(`automation-config-actor\0${actorId}`, 'utf8').digest('hex');
+}
+
+export class MysqlAutomationConfigRepository implements AutomationConfigRepository {
+ constructor(private readonly database: DurableWorkDatabase, private readonly outbox: OutboxService) {}
+
+ async read(input: { readonly tenantId: string; readonly assetId: string }): Promise {
+ const dataSource = await this.database.getDataSource();
+ const rows = await dataSource.query(
+ `SELECT private_welcome_message, comment_public_reply, comment_private_reply, version, updated_at, projection_state
+ FROM ca_meta_asset_automation_configs WHERE tenant_id = ? AND asset_id = ? LIMIT 1`,
+ [input.tenantId, input.assetId],
+ ) as AutomationConfigRow[];
+ return fromRow(rows[0]);
+ }
+
+ async update(input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise {
+ const dataSource = await this.database.getDataSource();
+ return dataSource.transaction(async (manager) => this.updateWithin(manager, input));
+ }
+
+ private async updateWithin(manager: EntityManager, input: {
+ readonly tenantId: string;
+ readonly assetId: string;
+ readonly actorId: string;
+ readonly requestId: string;
+ readonly patch: AutomationConfigPatch;
+ }): Promise {
+ const now = new Date().toISOString();
+ const timestamp = sqlTimestamp(now);
+ await manager.query(
+ `INSERT IGNORE INTO ca_meta_asset_automation_configs
+ (tenant_id, asset_id, private_welcome_message, comment_public_reply, comment_private_reply,
+ version, projection_state, created_at, updated_at)
+ VALUES (?, ?, NULL, NULL, NULL, 0, 'UNKNOWN', ?, NULL)`,
+ [input.tenantId, input.assetId, timestamp],
+ );
+ const rows = await manager.query(
+ `SELECT private_welcome_message, comment_public_reply, comment_private_reply, version, updated_at, projection_state
+ FROM ca_meta_asset_automation_configs WHERE tenant_id = ? AND asset_id = ? FOR UPDATE`,
+ [input.tenantId, input.assetId],
+ ) as AutomationConfigRow[];
+ const current = fromRow(rows[0]);
+ if (!rows[0] || current.version !== input.patch.version || current.version >= Number.MAX_SAFE_INTEGER) {
+ throw new AutomationConfigVersionConflictError();
+ }
+ const nextVersion = current.version + 1;
+ const next = Object.freeze({
+ privateWelcomeMessage: input.patch.privateWelcomeMessage === undefined ? current.privateWelcomeMessage : input.patch.privateWelcomeMessage || null,
+ commentPublicReply: input.patch.commentPublicReply === undefined ? current.commentPublicReply : input.patch.commentPublicReply || null,
+ commentPrivateReply: input.patch.commentPrivateReply === undefined ? current.commentPrivateReply : input.patch.commentPrivateReply || null,
+ version: nextVersion,
+ updatedAt: now,
+ projectionState: 'PENDING' as const,
+ });
+ const updateResult = await manager.query(
+ `UPDATE ca_meta_asset_automation_configs
+ SET private_welcome_message = ?, comment_public_reply = ?, comment_private_reply = ?,
+ version = ?, projection_state = 'PENDING', updated_at = ?
+ WHERE tenant_id = ? AND asset_id = ? AND version = ?`,
+ [next.privateWelcomeMessage, next.commentPublicReply, next.commentPrivateReply, nextVersion, timestamp,
+ input.tenantId, input.assetId, current.version],
+ );
+ if (!isRecordAffected(updateResult, 1)) {
+ throw new AutomationConfigVersionConflictError();
+ }
+ const changedByHash = actorHash(input.actorId);
+ const changedFields = ['privateWelcomeMessage', 'commentPublicReply', 'commentPrivateReply']
+ .filter((field) => Object.prototype.hasOwnProperty.call(input.patch, field));
+ await manager.query(
+ `INSERT INTO ca_meta_asset_automation_config_versions
+ (tenant_id, asset_id, version, changed_fields_json, actor_ref_hash, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [input.tenantId, input.assetId, nextVersion, JSON.stringify(changedFields), changedByHash, timestamp],
+ );
+ await this.outbox.appendWithin(manager, {
+ eventId: `automation-config:${input.assetId}:${nextVersion}`,
+ tenantRef: input.tenantId,
+ schemaVersion: 1,
+ eventType: 'automation.config.changed',
+ aggregateRef: input.assetId,
+ aggregateVersion: nextVersion,
+ actor: { kind: 'USER', ref: input.actorId },
+ correlationId: input.requestId,
+ causationId: input.requestId,
+ createdAt: now,
+ enqueuedAt: now,
+ data: { kind: 'AUTOMATION_CONFIG_CHANGED', assetRef: input.assetId, configurationVersion: nextVersion },
+ });
+ return next;
+ }
+}
diff --git a/apps/api/src/database/migrations/automation-config.ts b/apps/api/src/database/migrations/automation-config.ts
new file mode 100644
index 00000000..d1ee8f79
--- /dev/null
+++ b/apps/api/src/database/migrations/automation-config.ts
@@ -0,0 +1,144 @@
+import type { QueryRunner } from 'typeorm';
+import { migrationChecksum } from './migration-fingerprint.js';
+import { assertExpectedColumns, tableExists } from './schema-inspection.js';
+import type { DdlStep, VersionedMigration } from './migration.types.js';
+
+interface IndexShape {
+ readonly name: string;
+ readonly columns: readonly string[];
+ readonly unique: boolean;
+}
+
+async function verifyIndexes(queryRunner: QueryRunner, table: string, expected: readonly IndexShape[]): Promise {
+ const rows = await queryRunner.query(
+ 'SELECT index_name AS name, column_name, non_unique FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? ORDER BY index_name, seq_in_index',
+ [table],
+ ) as Array<{ name: string; column_name: string; non_unique: number | string }>;
+ const actual = new Map();
+ for (const row of rows) {
+ const index = actual.get(row.name) ?? { columns: [], unique: Number(row.non_unique) === 0 };
+ index.columns.push(row.column_name);
+ actual.set(row.name, index);
+ }
+ if (actual.size !== expected.length || expected.some((index) => {
+ const found = actual.get(index.name);
+ return !found || found.unique !== index.unique || found.columns.join('\0') !== index.columns.join('\0');
+ })) throw new Error(`Migration found an incompatible index on ${table}.`);
+}
+
+function tableStep(id: string, table: string, columns: readonly string[], indexes: readonly IndexShape[], sql: string): DdlStep {
+ return {
+ id,
+ phase: 'expand',
+ sql,
+ definitionVersion: 'v1',
+ definition: JSON.stringify({ table, columns, indexes, helpers: [assertExpectedColumns.toString(), verifyIndexes.toString()] }),
+ retryWhenAbsent: true,
+ verify: async ({ queryRunner }) => {
+ if (!(await tableExists(queryRunner, table))) return false;
+ await assertExpectedColumns(queryRunner, table, columns);
+ await verifyIndexes(queryRunner, table, indexes);
+ return true;
+ },
+ };
+}
+
+async function triggerExists(queryRunner: QueryRunner, name: string, event: 'UPDATE' | 'DELETE'): Promise {
+ const rows = await queryRunner.query(
+ 'SELECT action_timing, event_manipulation, event_object_table, action_statement FROM information_schema.triggers WHERE trigger_schema = DATABASE() AND trigger_name = ?',
+ [name],
+ ) as Array<{ action_timing: string; event_manipulation: string; event_object_table: string; action_statement: string }>;
+ if (rows.length === 0) return false;
+ const row = rows[0];
+ const statement = row?.action_statement.toUpperCase().replace(/[\s`]+/g, '');
+ if (row?.action_timing !== 'BEFORE' || row.event_manipulation !== event ||
+ row.event_object_table !== 'ca_meta_asset_automation_config_versions' ||
+ statement !== "SIGNALSQLSTATE'45000'SETMESSAGE_TEXT='APPEND-ONLYAUTOMATIONHISTORY'") {
+ throw new Error(`Migration found an incompatible trigger ${name}.`);
+ }
+ return true;
+}
+
+function immutableHistoryTrigger(id: string, event: 'UPDATE' | 'DELETE'): DdlStep {
+ const triggerName = `tr_ca_meta_automation_config_history_no_${event.toLowerCase()}`;
+ const statement = "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'append-only automation history'";
+ return {
+ id,
+ phase: 'expand',
+ sql: `CREATE TRIGGER ${triggerName} BEFORE ${event} ON ca_meta_asset_automation_config_versions FOR EACH ROW ${statement}`,
+ definitionVersion: 'v1',
+ definition: JSON.stringify({ triggerName, table: 'ca_meta_asset_automation_config_versions', event, statement }),
+ retryWhenAbsent: true,
+ verify: ({ queryRunner }) => triggerExists(queryRunner, triggerName, event),
+ };
+}
+
+const config = tableStep(
+ 'create-asset-automation-configs',
+ 'ca_meta_asset_automation_configs',
+ ['tenant_id', 'asset_id', 'private_welcome_message', 'comment_public_reply', 'comment_private_reply', 'version', 'projection_state', 'created_at', 'updated_at'],
+ [
+ { name: 'PRIMARY', columns: ['tenant_id', 'asset_id'], unique: true },
+ { name: 'ix_ca_meta_automation_config_projection', columns: ['projection_state', 'updated_at'], unique: false },
+ ],
+ `CREATE TABLE IF NOT EXISTS ca_meta_asset_automation_configs (
+ tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ private_welcome_message TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ comment_public_reply TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ comment_private_reply TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
+ version BIGINT UNSIGNED NOT NULL DEFAULT 0,
+ projection_state VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'UNKNOWN',
+ created_at DATETIME(6) NOT NULL,
+ updated_at DATETIME(6) NULL,
+ PRIMARY KEY (tenant_id, asset_id),
+ KEY ix_ca_meta_automation_config_projection (projection_state, updated_at),
+ CONSTRAINT ck_ca_meta_automation_config_projection CHECK (projection_state IN ('UNKNOWN', 'PENDING', 'SYNCED', 'STALE', 'FAILED')),
+ CONSTRAINT fk_ca_meta_automation_config_asset FOREIGN KEY (tenant_id, asset_id)
+ REFERENCES ca_meta_assets (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`,
+);
+
+const history = tableStep(
+ 'create-asset-automation-config-versions',
+ 'ca_meta_asset_automation_config_versions',
+ ['tenant_id', 'asset_id', 'version', 'changed_fields_json', 'actor_ref_hash', 'created_at'],
+ [
+ { name: 'PRIMARY', columns: ['tenant_id', 'asset_id', 'version'], unique: true },
+ { name: 'ix_ca_meta_automation_config_history_actor', columns: ['tenant_id', 'actor_ref_hash', 'created_at'], unique: false },
+ ],
+ `CREATE TABLE IF NOT EXISTS ca_meta_asset_automation_config_versions (
+ tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ version BIGINT UNSIGNED NOT NULL,
+ changed_fields_json JSON NOT NULL,
+ actor_ref_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ created_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (tenant_id, asset_id, version),
+ KEY ix_ca_meta_automation_config_history_actor (tenant_id, actor_ref_hash, created_at),
+ CONSTRAINT fk_ca_meta_automation_config_history_asset FOREIGN KEY (tenant_id, asset_id)
+ REFERENCES ca_meta_assets (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`,
+);
+
+const definition = {
+ id: '202611120023-asset-automation-config',
+ schemaVersion: 23,
+ minimumSupportedApiVersion: 1,
+ steps: [
+ config,
+ history,
+ immutableHistoryTrigger('automation-config-history-no-update', 'UPDATE'),
+ immutableHistoryTrigger('automation-config-history-no-delete', 'DELETE'),
+ ],
+} as const;
+
+export const AUTOMATION_CONFIG_MIGRATION: VersionedMigration = {
+ ...definition,
+ checksum: migrationChecksum(definition),
+};
+
+export const AUTOMATION_CONFIG_TABLES = [
+ 'ca_meta_asset_automation_configs',
+ 'ca_meta_asset_automation_config_versions',
+] as const;
diff --git a/apps/api/src/database/migrations/migrations.ts b/apps/api/src/database/migrations/migrations.ts
index fcd04dd3..fc7d6c18 100644
--- a/apps/api/src/database/migrations/migrations.ts
+++ b/apps/api/src/database/migrations/migrations.ts
@@ -21,6 +21,7 @@ import { ASSET_OBSERVATIONS_MIGRATION, ASSET_OBSERVATION_TABLES } from './asset-
import { CONNECTION_LIFECYCLE_MIGRATION, CONNECTION_LIFECYCLE_TABLES } from './connection-lifecycle.js';
import { INTERNAL_ASSET_OPERATIONS_MIGRATION, INTERNAL_ASSET_OPERATION_TABLES } from './internal-asset-operations.js';
import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from './onboarding-coexistence-lock.js';
+import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from './automation-config.js';
function ddl(
id: string,
@@ -293,6 +294,7 @@ const migrationsWithoutChecksums = [
CONNECTION_LIFECYCLE_MIGRATION,
INTERNAL_ASSET_OPERATIONS_MIGRATION,
ONBOARDING_COEXISTENCE_LOCK_MIGRATION,
+ AUTOMATION_CONFIG_MIGRATION,
] as const;
export const MIGRATIONS: readonly VersionedMigration[] = migrationsWithoutChecksums.map((migration) => ({
@@ -326,5 +328,6 @@ export const DOMAIN_TABLES = [
...CONNECTION_LIFECYCLE_TABLES,
...INTERNAL_ASSET_OPERATION_TABLES,
...ONBOARDING_COEXISTENCE_LOCK_TABLES,
+ ...AUTOMATION_CONFIG_TABLES,
] as const;
diff --git a/apps/api/src/identity/identity.service.ts b/apps/api/src/identity/identity.service.ts
index 1a852abf..ef794b5f 100644
--- a/apps/api/src/identity/identity.service.ts
+++ b/apps/api/src/identity/identity.service.ts
@@ -217,6 +217,7 @@ const ROLE_PERMISSIONS: Readonly new MysqlAutomationConfigRepository(database, outbox),
+ inject: [DURABLE_WORK_DATABASE, OutboxService],
+ },
+ {
+ provide: AutomationConfigService,
+ useFactory: (repository: AutomationConfigRepository, assets: MetaAssetsRepository,
+ contexts: TenantContextService, authorization: TenantAuthorizationService) =>
+ new AutomationConfigService(repository, assets, contexts, authorization, environment.nodeEnv),
+ inject: [AUTOMATION_CONFIG_REPOSITORY, META_ASSETS_REPOSITORY, TenantContextService, TenantAuthorizationService],
+ },
{ provide: CREDENTIAL_KEY_PROVIDER, useFactory: () => new ConfiguredCredentialKeyProvider(environment.credentialKeyRing) },
{ provide: CREDENTIAL_VAULT_AUTHORIZATION, useClass: FailClosedCredentialVaultAuthorization },
{
diff --git a/apps/api/test/m5-05-automation-config.integration.spec.ts b/apps/api/test/m5-05-automation-config.integration.spec.ts
new file mode 100644
index 00000000..d701e091
--- /dev/null
+++ b/apps/api/test/m5-05-automation-config.integration.spec.ts
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { DataSource, EntityManager } from 'typeorm';
+import type { ApiEnvironment } from '../src/config/environment.js';
+import { DurableWorkDatabase } from '../src/jobs/durable-work-database.js';
+import { DurableJobStore } from '../src/jobs/job-store.js';
+import { OutboxService } from '../src/outbox/outbox-service.js';
+import { AutomationConfigVersionConflictError } from '../src/automation-config/automation-config.types.js';
+import { MysqlAutomationConfigRepository } from '../src/automation-config/mysql-automation-config.repository.js';
+
+const enabled = process.env.M5_05_AUTOMATION_TEST === '1';
+const tenantId = '90000000-0000-4000-8000-000000000059';
+const appConfigurationId = '90000000-0000-4000-8000-000000000051';
+const firstAssetId = '90000000-0000-4000-8000-000000000052';
+const secondAssetId = '90000000-0000-4000-8000-000000000053';
+const actorId = '90000000-0000-4000-8000-000000000054';
+const requestId = 'req-m5-05-synthetic-0001';
+const message = 'M5.05 synthetic text never copied to the outbox';
+
+test('MariaDB transaction keeps asset settings isolated, rejects stale versions and stores only intent metadata in the outbox', { skip: !enabled }, async () => {
+ const required = [
+ 'API_DATABASE_HOST', 'API_DATABASE_PORT', 'API_DATABASE_NAME', 'API_DATABASE_USER', 'API_DATABASE_PASSWORD',
+ ] as const;
+ const missing = required.filter((name) => !process.env[name]);
+ assert.deepEqual(missing, [], 'scoped API database environment must be provided for the isolated integration test');
+
+ const environment = {
+ database: {
+ host: process.env.API_DATABASE_HOST!,
+ port: Number(process.env.API_DATABASE_PORT),
+ name: process.env.API_DATABASE_NAME!,
+ user: process.env.API_DATABASE_USER!,
+ password: process.env.API_DATABASE_PASSWORD!,
+ connectTimeoutMs: 5000,
+ },
+ } as ApiEnvironment;
+ const database = new DurableWorkDatabase(environment);
+ const dataSource = await database.getDataSource();
+ const runner = dataSource.createQueryRunner();
+ await runner.connect();
+ await runner.startTransaction();
+
+ try {
+ await runner.query(
+ `INSERT INTO ca_meta_tenants (id, status, version, created_at, updated_at)
+ VALUES (?, 'ACTIVE', 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, [tenantId],
+ );
+ await runner.query(
+ `INSERT INTO ca_meta_app_configurations
+ (id, tenant_id, provider, app_external_id, environment, credential_ref, version, created_at, updated_at)
+ VALUES (?, ?, 'META', 'm5-05-synthetic-app', 'test', NULL, 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`,
+ [appConfigurationId, tenantId],
+ );
+ for (const [assetId, externalId] of [[firstAssetId, 'm5-05-number-1'], [secondAssetId, 'm5-05-number-2']] as const) {
+ await runner.query(
+ `INSERT INTO ca_meta_assets
+ (id, tenant_id, app_configuration_id, provider, environment, asset_kind, external_id,
+ display_name, external_name, version, created_at, updated_at)
+ VALUES (?, ?, ?, 'META', 'test', 'WHATSAPP_PHONE_NUMBER', ?, 'Synthetic M5.05 asset',
+ 'Synthetic M5.05 asset', 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`,
+ [assetId, tenantId, appConfigurationId, externalId],
+ );
+ }
+
+ // Repository and outbox transactions share the test-owned transaction. Rolling it back
+ // removes every synthetic fixture and append-only audit row at the end of this test.
+ const scopedDataSource = {
+ query: (sql: string, parameters?: readonly unknown[]) => runner.manager.query(sql, parameters),
+ transaction: async (operation: (manager: EntityManager) => Promise): Promise => operation(runner.manager),
+ } as unknown as DataSource;
+ const scopedDatabase = {
+ getDataSource: async () => scopedDataSource,
+ } as unknown as DurableWorkDatabase;
+ const jobs = new DurableJobStore(scopedDatabase);
+ const outbox = new OutboxService(scopedDatabase, jobs);
+ const repository = new MysqlAutomationConfigRepository(scopedDatabase, outbox);
+
+ const saved = await repository.update({
+ tenantId, assetId: firstAssetId, actorId, requestId,
+ patch: { version: 0, privateWelcomeMessage: message },
+ });
+ const separateAsset = await repository.read({ tenantId, assetId: secondAssetId });
+ assert.equal(saved.version, 1);
+ assert.equal(saved.privateWelcomeMessage, message);
+ assert.equal(saved.projectionState, 'PENDING');
+ assert.equal(separateAsset.version, 0);
+ assert.equal(separateAsset.privateWelcomeMessage, null);
+
+ await assert.rejects(repository.update({
+ tenantId, assetId: firstAssetId, actorId, requestId: `${requestId}-stale`,
+ patch: { version: 0, privateWelcomeMessage: 'stale synthetic value' },
+ }), AutomationConfigVersionConflictError);
+
+ const historyRows = await runner.manager.query(
+ `SELECT version, changed_fields_json FROM ca_meta_asset_automation_config_versions
+ WHERE tenant_id = ? AND asset_id = ? ORDER BY version`, [tenantId, firstAssetId],
+ ) as Array<{ version: number | string; changed_fields_json: string | readonly string[] }>;
+ assert.equal(historyRows.length, 1);
+ assert.equal(Number(historyRows[0]?.version), 1);
+ const changedFields = typeof historyRows[0]?.changed_fields_json === 'string'
+ ? JSON.parse(historyRows[0].changed_fields_json) as readonly string[]
+ : historyRows[0]?.changed_fields_json;
+ assert.deepEqual(changedFields, ['privateWelcomeMessage']);
+
+ const outboxRows = await runner.manager.query(
+ `SELECT event_type, payload_json FROM ca_meta_outbox_events WHERE tenant_id = ? ORDER BY event_id`, [tenantId],
+ ) as Array<{ event_type: string; payload_json: string | object }>;
+ assert.equal(outboxRows.length, 1);
+ assert.equal(outboxRows[0]?.event_type, 'automation.config.changed');
+ const payload = typeof outboxRows[0]?.payload_json === 'string'
+ ? outboxRows[0].payload_json : JSON.stringify(outboxRows[0]?.payload_json);
+ assert.ok(payload);
+ assert.equal(payload.includes(message), false);
+ assert.match(payload, /AUTOMATION_CONFIG_CHANGED/);
+
+ const jobsRows = await runner.manager.query(
+ 'SELECT COUNT(*) AS count FROM ca_meta_jobs WHERE tenant_id = ? AND job_type = \'OUTBOX_DELIVER\'', [tenantId],
+ ) as Array<{ count: number | string }>;
+ assert.equal(Number(jobsRows[0]?.count), 1);
+ } finally {
+ if (runner.isTransactionActive) await runner.rollbackTransaction();
+ await runner.release();
+ await database.close();
+ }
+});
diff --git a/apps/api/test/m5-05-automation-config.spec.ts b/apps/api/test/m5-05-automation-config.spec.ts
new file mode 100644
index 00000000..515c559d
--- /dev/null
+++ b/apps/api/test/m5-05-automation-config.spec.ts
@@ -0,0 +1,296 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { components } from '@ca-meta/contracts';
+import { BadRequestException } from '@nestjs/common';
+import { AutomationConfigController } from '../src/automation-config/automation-config.controller.js';
+import { AutomationConfigService } from '../src/automation-config/automation-config.service.js';
+import { IdentityDeniedError } from '../src/identity/identity.errors.js';
+import type { IdentityRepository } from '../src/identity/identity.repository.js';
+import { TenantAuthorizationService, TenantContextService, hashTenantContextReference } from '../src/identity/identity.service.js';
+import { PortalSessionVerifier, type VerifiedPortalSession } from '../src/identity/identity.trust.js';
+import type { PortalRequest } from '../src/identity/portal-session.guard.js';
+import type { AuthorizedTenantScope, MembershipRole } from '../src/identity/identity.types.js';
+import type { AssetCatalogReadModel, MetaEnvironment } from '../src/meta-assets/meta-assets.types.js';
+import type { MetaAssetsRepository } from '../src/meta-assets/mysql-meta-assets.repository.js';
+import {
+ AutomationConfigNotFoundError,
+ AutomationConfigValidationError,
+ AutomationConfigVersionConflictError,
+ type AutomationConfigPatch,
+ type AutomationConfigRepository,
+ type AutomationConfigState,
+} from '../src/automation-config/automation-config.types.js';
+
+const tenantA = '00000000-0000-4000-8000-000000000001';
+const tenantB = '00000000-0000-4000-8000-000000000002';
+const principalId = '00000000-0000-4000-8000-000000000003';
+const appConfigurationId = '00000000-0000-4000-8000-000000000004';
+const numberOne = '00000000-0000-4000-8000-000000000011';
+const numberTwo = '00000000-0000-4000-8000-000000000012';
+const facebookPage = '00000000-0000-4000-8000-000000000013';
+const instagramAccount = '00000000-0000-4000-8000-000000000014';
+const contextRef = 'synthetic-current-tenant-context-01';
+const sessionToken = 'synthetic-portal-session-token-value-00000001';
+
+function asset(assetRef: string, kind: AssetCatalogReadModel['kind']): AssetCatalogReadModel {
+ return {
+ assetRef,
+ appConfigurationId,
+ kind,
+ displayLabel: `Synthetic ${kind}`,
+ status: 'ACTIVE',
+ authorizationStatus: 'VERIFIED',
+ observedAt: '2026-11-11T10:00:00.000Z',
+ lastVerifiedAt: '2026-11-11T10:00:00.000Z',
+ verificationSource: 'META_API',
+ stale: false,
+ capabilityEvidence: [],
+ diagnostics: {
+ onboardingStatus: 'UNKNOWN',
+ onboardingReadiness: 'UNKNOWN',
+ onboardingNextAction: 'UNKNOWN',
+ tokenHealth: 'UNKNOWN',
+ tokenHealthObservedAt: null,
+ metaState: 'UNKNOWN',
+ metaObservedAt: null,
+ subscriptionState: 'UNKNOWN',
+ projectionState: 'UNKNOWN',
+ },
+ reauthorizationRequired: false,
+ };
+}
+
+function setup(input: { readonly roles?: readonly MembershipRole[] } = {}) {
+ const roles = input.roles ?? ['owner'];
+ const identityRepository = {
+ async findMembership(requestPrincipalId: string, tenantId: string) {
+ if (requestPrincipalId !== principalId || ![tenantA, tenantB].includes(tenantId)) return undefined;
+ return {
+ principalId,
+ tenantId,
+ principalKind: 'USER' as const,
+ principalStatus: 'ACTIVE' as const,
+ tenantStatus: 'ACTIVE' as const,
+ roles,
+ status: 'ACTIVE' as const,
+ version: 1,
+ };
+ },
+ } as unknown as IdentityRepository;
+ const contexts = new TenantContextService(identityRepository);
+ const authorization = new TenantAuthorizationService(identityRepository);
+ const contextHash = hashTenantContextReference(contextRef);
+ const assets = new Map([
+ [`${tenantA}:${numberOne}`, asset(numberOne, 'WHATSAPP_PHONE_NUMBER')],
+ [`${tenantA}:${numberTwo}`, asset(numberTwo, 'WHATSAPP_PHONE_NUMBER')],
+ [`${tenantA}:${facebookPage}`, asset(facebookPage, 'FACEBOOK_PAGE')],
+ [`${tenantA}:${instagramAccount}`, asset(instagramAccount, 'INSTAGRAM_BUSINESS_ACCOUNT')],
+ ]);
+ const assetLookups: Array<{ tenantId: string; assetRef: string; environment: MetaEnvironment }> = [];
+ const assetRepository = {
+ async findAssetCatalog(query: { tenantId: string; assetRef: string; environment: MetaEnvironment }) {
+ assetLookups.push(query);
+ return assets.get(`${query.tenantId}:${query.assetRef}`);
+ },
+ } as unknown as MetaAssetsRepository;
+ const rows = new Map();
+ const history: Array<{ tenantId: string; assetId: string; version: number; fields: readonly string[] }> = [];
+ const configRepository: AutomationConfigRepository = {
+ async read(query) {
+ return rows.get(`${query.tenantId}:${query.assetId}`) ?? Object.freeze({
+ privateWelcomeMessage: null, commentPublicReply: null, commentPrivateReply: null,
+ version: 0, updatedAt: null, projectionState: 'UNKNOWN',
+ });
+ },
+ async update(query) {
+ const key = `${query.tenantId}:${query.assetId}`;
+ const current = rows.get(key) ?? Object.freeze({
+ privateWelcomeMessage: null, commentPublicReply: null, commentPrivateReply: null,
+ version: 0, updatedAt: null, projectionState: 'UNKNOWN',
+ });
+ if (query.patch.version !== current.version) throw new AutomationConfigVersionConflictError();
+ const next = Object.freeze({
+ privateWelcomeMessage: query.patch.privateWelcomeMessage === undefined ? current.privateWelcomeMessage : query.patch.privateWelcomeMessage || null,
+ commentPublicReply: query.patch.commentPublicReply === undefined ? current.commentPublicReply : query.patch.commentPublicReply || null,
+ commentPrivateReply: query.patch.commentPrivateReply === undefined ? current.commentPrivateReply : query.patch.commentPrivateReply || null,
+ version: current.version + 1,
+ updatedAt: '2026-11-11T10:01:00.000Z',
+ projectionState: 'PENDING' as const,
+ });
+ rows.set(key, next);
+ history.push({ tenantId: query.tenantId, assetId: query.assetId, version: next.version,
+ fields: Object.keys(query.patch).filter((field) => field !== 'version') });
+ return next;
+ },
+ };
+ const service = new AutomationConfigService(configRepository, assetRepository, contexts, authorization, 'test');
+ const sessions = new PortalSessionVerifier({
+ async findActiveBySecretHash() {
+ return {
+ sessionId: '00000000-0000-4000-8000-000000000005',
+ principalId,
+ tenantId: tenantA,
+ contextVersion: 1,
+ contextRefHash: contextHash,
+ csrfTokenHash: 'a'.repeat(64),
+ entryMode: 'PORTAL_TOP_LEVEL' as const,
+ };
+ },
+ });
+ return {
+ service,
+ sessions,
+ rows,
+ history,
+ assetLookups,
+ async session(tenantId: string = tenantA): Promise {
+ const tenantSessionVerifier = tenantId === tenantA ? sessions : new PortalSessionVerifier({
+ async findActiveBySecretHash() {
+ return {
+ sessionId: '00000000-0000-4000-8000-000000000006', principalId, tenantId,
+ contextVersion: 1, contextRefHash: contextHash, csrfTokenHash: 'b'.repeat(64), entryMode: 'PORTAL_TOP_LEVEL' as const,
+ };
+ },
+ });
+ const result = await tenantSessionVerifier.verify(sessionToken);
+ assert.ok(result);
+ return result;
+ },
+ };
+}
+
+test('channel capabilities keep WhatsApp number and Facebook/Instagram reply fields distinct and non-executable', async () => {
+ const state = setup();
+ const session = await state.session();
+ const whatsapp = await state.service.read(session, contextRef, numberOne, 'req-synthetic-automation-read');
+ const facebook = await state.service.read(session, contextRef, facebookPage, 'req-synthetic-automation-read');
+ const instagram = await state.service.read(session, contextRef, instagramAccount, 'req-synthetic-automation-read');
+
+ assert.equal(whatsapp.assetKind, 'WHATSAPP_PHONE_NUMBER');
+ assert.deepEqual(whatsapp.supportedFields, ['privateWelcomeMessage']);
+ assert.deepEqual(facebook.supportedFields, ['commentPublicReply', 'commentPrivateReply']);
+ assert.deepEqual(instagram.supportedFields, ['commentPublicReply', 'commentPrivateReply']);
+ assert.equal(whatsapp.inheritanceMode, 'ASSET_ONLY');
+ assert.equal(whatsapp.capabilities.executable, false);
+ assert.equal(whatsapp.capabilities.canEdit, true);
+});
+
+test('controller returns strong version ETags and rejects malformed preconditions', async () => {
+ const responseBody: components['schemas']['AutomationConfig'] = {
+ assetId: numberOne,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-controller-01',
+ };
+ const controller = new AutomationConfigController({
+ async read() { return responseBody; },
+ async update() { return { ...responseBody, version: 1, projectionState: 'PENDING' }; },
+ } as unknown as AutomationConfigService);
+ const request = { requestId: 'req-synthetic-controller-01', portalSession: {} as VerifiedPortalSession } as PortalRequest;
+ const headers = new Map();
+ const response = { setHeader(name: string, value: string) { headers.set(name, value); } } as unknown as import('express').Response;
+ const currentContext = 'synthetic-current-tenant-context-01';
+
+ await controller.read(numberOne, request, currentContext, response);
+ assert.equal(headers.get('ETag'), '"automation-v0"');
+ const updated = await controller.update(numberOne, { version: 0, privateWelcomeMessage: 'Synthetic' },
+ request, currentContext, '"automation-v0"', response);
+ assert.equal(updated.version, 1);
+ assert.equal(headers.get('ETag'), '"automation-v1"');
+ await assert.rejects(controller.update(numberOne, { version: 1, privateWelcomeMessage: 'stale' },
+ request, currentContext, 'W/"automation-v1"', response), BadRequestException);
+});
+
+test('updates stay isolated per number and only supported fields create history', async () => {
+ const state = setup();
+ const session = await state.session();
+ const saved = await state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ privateWelcomeMessage: 'Synthetic welcome',
+ });
+ const untouchedNumber = await state.service.read(session, contextRef, numberTwo, 'req-synthetic-automation-read');
+ assert.equal(saved.privateWelcomeMessage, 'Synthetic welcome');
+ assert.equal(saved.version, 1);
+ assert.equal(saved.projectionState, 'PENDING');
+ assert.equal(untouchedNumber.privateWelcomeMessage, null);
+ assert.equal(untouchedNumber.version, 0);
+ assert.deepEqual(state.history.map((entry) => [entry.assetId, entry.version, entry.fields]), [
+ [numberOne, 1, ['privateWelcomeMessage']],
+ ]);
+
+ const cleared = await state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-clear', 1, {
+ version: 1,
+ privateWelcomeMessage: '',
+ });
+ assert.equal(cleared.privateWelcomeMessage, null);
+ assert.equal(cleared.version, 2);
+
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-save', 1, {
+ version: 1,
+ privateWelcomeMessage: 'not supported here',
+ commentPublicReply: 'unsupported on a WhatsApp number',
+ }), AutomationConfigValidationError);
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ privateWelcomeMessage: 'unsupported on a Facebook page',
+ }), AutomationConfigValidationError);
+});
+
+test('stale ETag/body version cannot overwrite a newer configuration', async () => {
+ const state = setup();
+ const session = await state.session();
+ await state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-save', 0, {
+ version: 0,
+ commentPublicReply: 'Synthetic public reply',
+ });
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-stale', 0, {
+ version: 0,
+ commentPrivateReply: 'stale update',
+ }), AutomationConfigVersionConflictError);
+ await assert.rejects(state.service.update(session, contextRef, facebookPage, 'req-synthetic-automation-stale', 1, {
+ version: 0,
+ commentPrivateReply: 'mismatched ETag and body',
+ }), AutomationConfigVersionConflictError);
+ const current = await state.service.read(session, contextRef, facebookPage, 'req-synthetic-automation-read');
+ assert.equal(current.version, 1);
+ assert.equal(current.commentPublicReply, 'Synthetic public reply');
+ assert.equal(current.commentPrivateReply, null);
+});
+
+test('viewer reads are safe but cannot update; foreign-tenant asset IDs are not found', async () => {
+ const viewerState = setup({ roles: ['viewer'] });
+ const viewer = await viewerState.session();
+ const readable = await viewerState.service.read(viewer, contextRef, numberOne, 'req-synthetic-automation-read');
+ assert.equal(readable.capabilities.canEdit, false);
+ await assert.rejects(viewerState.service.update(viewer, contextRef, numberOne, 'req-synthetic-automation-save', 0, {
+ version: 0, privateWelcomeMessage: 'blocked viewer write',
+ }), IdentityDeniedError);
+
+ const ownerState = setup();
+ const foreignTenantSession = await ownerState.session(tenantB);
+ await assert.rejects(ownerState.service.read(foreignTenantSession, contextRef, numberOne, 'req-synthetic-automation-read'), AutomationConfigNotFoundError);
+ assert.ok(ownerState.assetLookups.every((lookup) => lookup.tenantId === tenantB));
+ assert.equal(ownerState.rows.size, 0);
+});
+
+test('oversized and control-character message bodies fail validation before persistence', async () => {
+ const state = setup();
+ const session = await state.session();
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-invalid', 0, {
+ version: 0,
+ privateWelcomeMessage: 'x'.repeat(4097),
+ }), AutomationConfigValidationError);
+ await assert.rejects(state.service.update(session, contextRef, numberOne, 'req-synthetic-automation-invalid', 0, {
+ version: 0,
+ privateWelcomeMessage: 'bad\u0000text',
+ }), AutomationConfigValidationError);
+ assert.equal(state.rows.size, 0);
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index fffd38a4..bc6fcce6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,6 +11,7 @@
"test:m5-02": "node ../../tools/m5-02/run-focused-web-tests.mjs",
"test:m5-03": "node ../../tools/m5-03/run-focused-web-tests.mjs",
"test:m5-04": "node ../../tools/m5-04/run-focused-web-tests.mjs",
+ "test:m5-05": "node ../../tools/m5-05/run-focused-web-tests.mjs",
"typecheck": "ngc -p tsconfig.app.json --noEmit",
"lint": "node ../../tools/m2/check-web-boundary.mjs"
},
diff --git a/apps/web/src/app/core/portal/automation-config.service.spec.ts b/apps/web/src/app/core/portal/automation-config.service.spec.ts
new file mode 100644
index 00000000..cdca773d
--- /dev/null
+++ b/apps/web/src/app/core/portal/automation-config.service.spec.ts
@@ -0,0 +1,69 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import type { components } from '@ca-meta/contracts';
+import { META_API_BASE_URL } from '../config/public-runtime-config';
+import { AutomationConfigService } from './automation-config.service';
+
+type AutomationConfig = components['schemas']['AutomationConfig'];
+
+describe('AutomationConfigService', () => {
+ let service: AutomationConfigService;
+ let http: HttpTestingController;
+
+ const config: AutomationConfig = {
+ assetId: '00000000-0000-4000-8000-000000000051',
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-automation-ui-01',
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: META_API_BASE_URL, useValue: '/api' },
+ ],
+ });
+ service = TestBed.inject(AutomationConfigService);
+ http = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => http.verify());
+
+ it('uses the internal asset endpoint and enforces response ETag versioning for reads and updates', async () => {
+ const loaded = service.read(config.assetId);
+ const readRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ expect(readRequest.request.method).toBe('GET');
+ readRequest.flush(config, { headers: { ETag: '"automation-v0"' } });
+ const current = await loaded;
+ expect(current.etag).toBe('"automation-v0"');
+
+ const update = { version: 0, privateWelcomeMessage: 'Synthetic portal value' };
+ const saving = service.update(config.assetId, update, current.etag);
+ const updateRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ expect(updateRequest.request.method).toBe('PATCH');
+ expect(updateRequest.request.headers.get('If-Match')).toBe('"automation-v0"');
+ expect(updateRequest.request.body).toEqual(update);
+ updateRequest.flush({ ...config, privateWelcomeMessage: update.privateWelcomeMessage, version: 1 }, {
+ headers: { ETag: '"automation-v1"' },
+ });
+ await expect(saving).resolves.toMatchObject({ etag: '"automation-v1"', config: { version: 1 } });
+ });
+
+ it('rejects a response whose ETag does not match the body version', async () => {
+ const loaded = service.read(config.assetId);
+ const request = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ request.flush(config, { headers: { ETag: '"automation-v1"' } });
+ await expect(loaded).rejects.toThrow('The automation config response is incomplete.');
+ });
+});
diff --git a/apps/web/src/app/core/portal/automation-config.service.ts b/apps/web/src/app/core/portal/automation-config.service.ts
new file mode 100644
index 00000000..f531f10d
--- /dev/null
+++ b/apps/web/src/app/core/portal/automation-config.service.ts
@@ -0,0 +1,49 @@
+import { HttpClient, HttpResponse } from '@angular/common/http';
+import { Inject, Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { META_API_BASE_URL } from '../config/public-runtime-config';
+import { normalizeApiV1BaseUrl } from './portal-session.service';
+
+export type AutomationConfig = components['schemas']['AutomationConfig'];
+export type AutomationConfigUpdate = components['schemas']['AutomationConfigUpdate'];
+
+export interface VersionedAutomationConfig {
+ readonly config: AutomationConfig;
+ readonly etag: string;
+}
+
+function validEtag(value: string | null, version: number): value is string {
+ return value === `"automation-v${version}"`;
+}
+
+@Injectable({ providedIn: 'root' })
+export class AutomationConfigService {
+ private readonly baseUrl: string;
+
+ constructor(private readonly http: HttpClient, @Inject(META_API_BASE_URL) baseUrl: string) {
+ this.baseUrl = normalizeApiV1BaseUrl(baseUrl);
+ }
+
+ async read(assetId: string): Promise {
+ const response = await firstValueFrom(this.http.get(
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config`, { observe: 'response' },
+ ));
+ return this.versioned(response);
+ }
+
+ async update(assetId: string, update: AutomationConfigUpdate, etag: string): Promise {
+ const response = await firstValueFrom(this.http.patch(
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config`, update,
+ { observe: 'response', headers: { 'If-Match': etag } },
+ ));
+ return this.versioned(response);
+ }
+
+ private versioned(response: HttpResponse): VersionedAutomationConfig {
+ const config = response.body;
+ const etag = response.headers.get('ETag');
+ if (!config || !validEtag(etag, config.version)) throw new Error('The automation config response is incomplete.');
+ return Object.freeze({ config, etag });
+ }
+}
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.html b/apps/web/src/app/features/portal/asset-automation-config.component.html
new file mode 100644
index 00000000..024325d5
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.html
@@ -0,0 +1,41 @@
+
+ Mensagens e respostas
+ Cada número ou página mantém sua própria configuração. Valores de conta não são copiados automaticamente para outros recursos.
+
+ Carregando configuração…
+
+ {{ errorMessage }} Referência: {{ errorRequestId }}
+
+
+
+ Não há campos de mensagem disponíveis para este tipo de recurso.
+
+
+ {{ savedMessage }}
+
+ Sincronização empresarial:
+
+ pendente; a configuração central já foi salva.
+ confirmada.
+ desatualizada.
+ falhou e pode precisar de reconciliação.
+ não confirmada.
+
+
+ Salvar esta configuração não envia mensagens. A execução continua indisponível até que as regras e permissões de cada canal sejam confirmadas.
+
+ Detalhes técnicos para suporte
+ Versão {{ current.version }} · atualização: {{ current.updatedAt || 'ainda não salva' }} · referência: {{ current.requestId }}
+
+
+
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.scss b/apps/web/src/app/features/portal/asset-automation-config.component.scss
new file mode 100644
index 00000000..ab52fb11
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.scss
@@ -0,0 +1,19 @@
+:host { display: block; }
+.automation-config { margin-top: 1.25rem; padding: 1.1rem; border: 1px solid #e1e6ea; border-radius: 9px; background: #fff; }
+h2 { margin: 0; font-size: 1.2rem; }
+.intro, .note, .projection, .state, .field small, details { color: #53616c; line-height: 1.5; }
+.field { display: grid; gap: .4rem; margin: 1rem 0; }
+label { color: #263640; font-weight: 650; }
+textarea { width: 100%; box-sizing: border-box; resize: vertical; border: 1px solid #aebbc5; border-radius: 6px; padding: .65rem; color: #253640; background: #fff; font: inherit; line-height: 1.45; }
+textarea:focus-visible, button:focus-visible, summary:focus-visible { outline: 3px solid #145da0; outline-offset: 2px; }
+textarea:disabled { color: #59666f; background: #f3f5f6; }
+.field small { font-size: .82rem; }
+button { min-height: 42px; border: 1px solid #0a5eb7; border-radius: 6px; padding: .55rem .9rem; color: #fff; background: #0866c6; font: inherit; font-weight: 650; cursor: pointer; }
+button:disabled { border-color: #c5cdd3; color: #697681; background: #edf0f2; cursor: not-allowed; }
+.warning { padding: .7rem .8rem; border-left: 3px solid #a56b00; color: #624110; background: #fff9e8; line-height: 1.45; }
+.error { padding: .7rem .8rem; border: 1px solid #f0c7c7; border-radius: 6px; color: #8f2525; background: #fff3f3; }
+.saved { padding: .65rem .75rem; border-left: 3px solid #237443; color: #175931; background: #edf8f0; }
+.projection { margin-top: 1rem; }
+.note { font-size: .87rem; }
+summary { width: fit-content; cursor: pointer; }
+details p { font-size: .85rem; overflow-wrap: anywhere; }
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
new file mode 100644
index 00000000..133d88f5
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
@@ -0,0 +1,55 @@
+import { ActivatedRoute } from '@angular/router';
+import { TestBed } from '@angular/core/testing';
+import { NEVER } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { AutomationConfigService } from '../../core/portal/automation-config.service';
+import { PortalContextStore } from '../../core/portal/portal-context.store';
+import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
+import { AssetAutomationConfigComponent } from './asset-automation-config.component';
+
+type AutomationConfig = components['schemas']['AutomationConfig'];
+
+describe('AssetAutomationConfigComponent', () => {
+ it('shows channel-supported fields and keeps edits disabled until the tenant identity gate is confirmed', async () => {
+ const config: AutomationConfig = {
+ assetId: '00000000-0000-4000-8000-000000000051',
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage: null,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version: 0,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: 'req-synthetic-automation-ui-02',
+ };
+ const settings = { read: vi.fn(), update: vi.fn() };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: NEVER } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: { contextChanged$: NEVER } },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: false,
+ reason: 'TENANT_IDENTITY_UNCONFIRMED',
+ message: 'Identidade da empresa não confirmada',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.componentInstance.config = config;
+ fixture.componentInstance.isLoading = false;
+ fixture.detectChanges();
+ const view = fixture.nativeElement as HTMLElement;
+ const fields = Array.from(view.querySelectorAll('textarea')).map((field) => field.id);
+ expect(fields).toEqual(['privateWelcomeMessage']);
+ expect(view.textContent).toContain('Identidade da empresa não confirmada');
+ expect((view.querySelector('button[type="submit"]') as HTMLButtonElement).disabled).toBe(true);
+ expect(settings.update).not.toHaveBeenCalled();
+ fixture.destroy();
+ });
+});
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.ts b/apps/web/src/app/features/portal/asset-automation-config.component.ts
new file mode 100644
index 00000000..8cf91e27
--- /dev/null
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.ts
@@ -0,0 +1,149 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Subject, takeUntil } from 'rxjs';
+import type { components } from '@ca-meta/contracts';
+import { AutomationConfigService, type VersionedAutomationConfig } from '../../core/portal/automation-config.service';
+import { PortalContextStore } from '../../core/portal/portal-context.store';
+import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
+import { safeRequestId } from '../../core/portal/safe-api-error';
+
+type AutomationConfig = components['schemas']['AutomationConfig'];
+type AutomationConfigField = components['schemas']['AutomationConfigField'];
+type AutomationConfigUpdate = components['schemas']['AutomationConfigUpdate'];
+type EditableValues = {
+ privateWelcomeMessage: string;
+ commentPublicReply: string;
+ commentPrivateReply: string;
+};
+
+const FIELD_LABELS: Readonly> = Object.freeze({
+ privateWelcomeMessage: 'Mensagem privada de boas-vindas',
+ commentPublicReply: 'Resposta pública a comentários',
+ commentPrivateReply: 'Resposta privada a comentários',
+});
+
+@Component({
+ selector: 'app-asset-automation-config',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ templateUrl: './asset-automation-config.component.html',
+ styleUrl: './asset-automation-config.component.scss',
+})
+export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
+ config: AutomationConfig | null = null;
+ form: EditableValues = { privateWelcomeMessage: '', commentPublicReply: '', commentPrivateReply: '' };
+ isLoading = true;
+ isSaving = false;
+ errorMessage = '';
+ errorRequestId: string | null = null;
+ savedMessage = '';
+ private etag = '';
+ private assetId = '';
+ private generation = 0;
+ private readonly destroy$ = new Subject();
+
+ constructor(
+ private readonly route: ActivatedRoute,
+ private readonly settings: AutomationConfigService,
+ private readonly context: PortalContextStore,
+ readonly tenantIdentity: PortalTenantIdentityService,
+ ) {}
+
+ get supportedFields(): readonly AutomationConfigField[] { return this.config?.supportedFields ?? []; }
+ get canSave(): boolean {
+ return !!this.config?.capabilities.canEdit && this.tenantIdentity.current.confirmed && this.isDirty && !this.isSaving;
+ }
+ get isDirty(): boolean {
+ if (!this.config) return false;
+ return this.supportedFields.some((field) => this.form[field] !== (this.config?.[field] ?? ''));
+ }
+ get writesUnavailableCopy(): string {
+ return this.tenantIdentity.current.confirmed
+ ? ''
+ : 'Identidade da empresa não confirmada. A edição de mensagens está indisponível neste ambiente.';
+ }
+
+ ngOnInit(): void {
+ this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ this.assetId = params.get('id') ?? '';
+ void this.load();
+ });
+ this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => { void this.load(); });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ fieldLabel(field: AutomationConfigField): string { return FIELD_LABELS[field]; }
+ supports(field: AutomationConfigField): boolean { return this.supportedFields.includes(field); }
+
+ async save(): Promise {
+ if (!this.config || !this.canSave || !this.etag) return;
+ const update = {
+ version: this.config.version,
+ ...(this.supports('privateWelcomeMessage') && this.form.privateWelcomeMessage !== (this.config.privateWelcomeMessage ?? '')
+ ? { privateWelcomeMessage: this.form.privateWelcomeMessage } : {}),
+ ...(this.supports('commentPublicReply') && this.form.commentPublicReply !== (this.config.commentPublicReply ?? '')
+ ? { commentPublicReply: this.form.commentPublicReply } : {}),
+ ...(this.supports('commentPrivateReply') && this.form.commentPrivateReply !== (this.config.commentPrivateReply ?? '')
+ ? { commentPrivateReply: this.form.commentPrivateReply } : {}),
+ } satisfies AutomationConfigUpdate;
+ if (Object.keys(update).length < 2) return;
+ this.isSaving = true;
+ this.errorMessage = '';
+ this.savedMessage = '';
+ this.errorRequestId = null;
+ try {
+ const next = await this.settings.update(this.assetId, update, this.etag);
+ this.setConfig(next);
+ this.savedMessage = 'Configuração salva no catálogo central. A sincronização empresarial está pendente quando indicada abaixo.';
+ } catch (error: unknown) {
+ this.errorMessage = error instanceof HttpErrorResponse && error.status === 412
+ ? 'A configuração mudou em outra sessão. Atualize e revise os valores antes de salvar.'
+ : 'Não foi possível salvar a configuração. Nenhuma mensagem foi enviada.';
+ this.errorRequestId = safeRequestId(error);
+ } finally {
+ this.isSaving = false;
+ }
+ }
+
+ private async load(): Promise {
+ const generation = ++this.generation;
+ this.config = null;
+ this.isLoading = true;
+ this.errorMessage = '';
+ this.errorRequestId = null;
+ this.savedMessage = '';
+ if (!this.assetId) {
+ this.isLoading = false;
+ this.errorMessage = 'Este recurso não foi encontrado.';
+ return;
+ }
+ try {
+ const current = await this.settings.read(this.assetId);
+ if (generation !== this.generation) return;
+ this.setConfig(current);
+ } catch (error: unknown) {
+ if (generation !== this.generation) return;
+ this.errorMessage = 'Não foi possível carregar a configuração deste recurso.';
+ this.errorRequestId = safeRequestId(error);
+ } finally {
+ if (generation === this.generation) this.isLoading = false;
+ }
+ }
+
+ private setConfig(value: VersionedAutomationConfig): void {
+ this.config = value.config;
+ this.etag = value.etag;
+ this.form = {
+ privateWelcomeMessage: value.config.privateWelcomeMessage ?? '',
+ commentPublicReply: value.config.commentPublicReply ?? '',
+ commentPrivateReply: value.config.commentPrivateReply ?? '',
+ };
+ }
+}
diff --git a/apps/web/src/app/features/portal/asset-detail.component.ts b/apps/web/src/app/features/portal/asset-detail.component.ts
index 5002abee..92ec23de 100644
--- a/apps/web/src/app/features/portal/asset-detail.component.ts
+++ b/apps/web/src/app/features/portal/asset-detail.component.ts
@@ -1,16 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { AssetAutomationConfigComponent } from './asset-automation-config.component';
@Component({
selector: 'app-asset-detail',
standalone: true,
- imports: [CommonModule, RouterLink],
+ imports: [CommonModule, RouterLink, AssetAutomationConfigComponent],
template: `
← Voltar às conexões
Detalhes do recurso
- Os detalhes serão exibidos após a verificação do ativo e da autorização pelo servidor.
+
`,
styles: [`
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.html b/apps/web/src/app/features/portal/connection-record-detail.component.html
index cb9679d7..c87041e5 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.html
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.html
@@ -80,6 +80,8 @@ {{ asset.displayLabel || 'Identificação não confirmada' }}
Última confirmação {{ asset.lastVerifiedAt || asset.observedAt }}. O status pode ter mudado; isso não confirma perda do recurso.
+ Configurar mensagens e respostas deste recurso
+
Solicitar reautorização
Desconectar este recurso
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.scss b/apps/web/src/app/features/portal/connection-record-detail.component.scss
index 53a1a21a..bd04303b 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.scss
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.scss
@@ -30,6 +30,9 @@ h3 { margin: .2rem 0 0; overflow-wrap: anywhere; font-size: 1.05rem; }
.dimension-state { color: #66737e; font-size: .82rem; }
.dimension-list small { display: block; color: #63717c; line-height: 1.4; overflow-wrap: anywhere; }
.stale-note { margin: .9rem 0 0; padding: .65rem .75rem; border-left: 3px solid #a56b00; color: #60420d; background: #fff9e8; line-height: 1.45; }
+.config-link { display: inline-block; margin-top: .85rem; color: #0067d8; font-weight: 650; text-decoration: none; }
+.config-link:hover { text-decoration: underline; }
+.config-link:focus-visible { outline: 3px solid #145da0; outline-offset: 2px; }
.asset-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; margin-top: 1rem; padding-top: .8rem; border-top: 1px solid #e6ebee; }
.asset-actions button { min-height: 40px; border: 1px solid #c5cdd3; border-radius: 6px; padding: .45rem .7rem; color: #59666f; background: #edf0f2; font: inherit; font-weight: 600; cursor: not-allowed; }
.asset-actions p { flex-basis: 100%; margin: 0; color: #624110; font-size: .85rem; line-height: 1.45; }
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts b/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
index a2d6ae2f..7f6fdea6 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.spec.ts
@@ -109,6 +109,8 @@ describe('ConnectionRecordDetailComponent', () => {
expect(Array.from(view.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
expect(view.querySelector('details summary')?.textContent).toContain('Detalhes técnicos para suporte');
expect(view.textContent).toContain('req-synthetic-connection-02');
+ expect(view.querySelector('.config-link')?.textContent).toContain('Configurar mensagens e respostas deste recurso');
+ expect(view.querySelector('.config-link')?.getAttribute('href')).toBe('/assets/00000000-0000-4000-8000-000000000012');
fixture.destroy();
});
});
diff --git a/apps/web/src/app/features/portal/connection-record-detail.component.ts b/apps/web/src/app/features/portal/connection-record-detail.component.ts
index 885697d7..b4af2380 100644
--- a/apps/web/src/app/features/portal/connection-record-detail.component.ts
+++ b/apps/web/src/app/features/portal/connection-record-detail.component.ts
@@ -82,6 +82,9 @@ export class ConnectionRecordDetailComponent implements OnInit, OnDestroy {
canShowReauthorization(asset: ConnectionAsset): boolean { return asset.capabilities?.canReauthorize === true; }
canShowDisconnect(asset: ConnectionAsset): boolean { return asset.capabilities?.canDisconnect === true; }
+ canShowAutomationConfig(asset: ConnectionAsset): boolean {
+ return asset.kind === 'PHONE_NUMBER' || asset.kind === 'PAGE' || asset.kind === 'INSTAGRAM_ACCOUNT';
+ }
/** SessionContext has no safe company label in the current contract, so tenant writes stay disabled. */
get writesAllowed(): boolean { return false; }
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
new file mode 100644
index 00000000..939f09f0
--- /dev/null
+++ b/docs/delivery/M5/M5.05.md
@@ -0,0 +1,59 @@
+# M5.05 — Configuração local de mensagens e respostas
+
+## Escopo e decisões
+
+Issue [#59](https://github.com/costaapps/ca_meta_app/issues/59), implementada no branch `desktop/m5-05` sobre a base M5.02 `ae867c6`.
+
+O catálogo central mantém `PrivateWelcomeMessage` por número do WhatsApp e `CommentPublicReply`/`CommentPrivateReply` por Página do Facebook ou conta profissional do Instagram. O contrato retorna os campos autorizados por tipo de ativo, modo `ASSET_ONLY`, versão, capabilities e estado da projeção. Uma configuração ausente começa com `null`; não há valor padrão global nem herança. Texto vazio limpa apenas o campo e o ativo enviados. Portanto, salvar para um número não altera os outros números da WABA.
+
+Leituras exigem sessão verificada, contexto tenant atual, membership ativa e `RESOURCE_READ`. Gravações exigem `MANAGE_AUTOMATION_CONFIG` e papel `owner`, `admin` ou `operator`; assets de outro tenant não são encontrados. O detalhe da conexão liga cada ativo suportado ao editor do próprio asset. A UI só oferece campos recebidos em `supportedFields` e mantém Salvar indisponível enquanto o gate de identidade M1.08 continuar sem empresa confirmada.
+
+`GET /api/v1/assets/{assetId}/automation-config` retorna ETag forte `"automation-vN"`. `PATCH` exige `If-Match` e versão do corpo correspondentes. O repositório bloqueia a linha do ativo e compara a versão antes de gravar; conflitos retornam `412` sem substituir o estado atual. Campos têm limite de 4096 caracteres e rejeitam controles inválidos. Erros de asset indisponível, precondição e conteúdo inválido retornam `404`, `412` e `422` com mensagens seguras.
+
+O valor atual, o histórico append-only de metadados, o evento `automation.config.changed` e o job de entrega local são gravados na mesma transação MySQL. O histórico registra campos alterados, hash do ator, versão e horário; não replica os textos. O evento contém somente referência interna do asset e versão. A configuração fica vinculada à chave interna `(tenant_id, asset_id)`, fora dos registros de credencial/autorização; o fluxo de reautorização não a limpa. O estado inicial é `UNKNOWN`, passa a `PENDING` ao salvar e não passa a `SYNCED` sem projeção confirmada.
+
+## Critérios e evidência
+
+| Critério | Estado | Evidência local |
+|---|---|---|
+| Campos por canal e por ativo; leitura e gravação tenant-scoped | Implementado | `automation-config.service.ts`, `mysql-automation-config.repository.ts`; testes cobrem WABA-number isolado, Página/Instagram, viewer e asset cross-tenant. |
+| Defaults/herança explícitos sem fanout na WABA | Implementado | Contrato `ASSET_ONLY`; defaults `null`; chave `(tenant_id, asset_id)`; teste mantém o segundo número sem alteração. Campo vazio limpa somente o ativo atual. |
+| Version/ETag e conflito sem sobrescrita | Implementado | Controller envia e valida `"automation-vN"`; serviço confere ETag/corpo; repositório usa transação, `FOR UPDATE` e CAS. Teste de controller e teste MariaDB cobrem versões obsoletas. |
+| Preservar estado durante reautorização | Implementado no catálogo local | Configuração separada de credenciais e autorizações, referenciada pelo UUID interno do asset; reautorização atualiza vínculos do catálogo e não tem rotina de exclusão desta configuração. Continuidade depende de a resolução do ativo reutilizar o mesmo UUID interno. |
+| Estado de projeção/outbox sem alegar transação distribuída | Implementado localmente | Estado `PENDING`; outbox/job transacional somente no MariaDB. Nenhuma confirmação externa é emitida. |
+| Conteúdo e tamanho validados; erros seguros | Implementado | Limite 4096, controles proibidos, DTO estrito, respostas `400`/`404`/`412`/`422`; testes de texto oversized, controle, ETag e conflito. |
+| UI de edição/consulta preserva o gate existente | Implementado com gravação fechada no estado atual | Link por asset a partir do detalhe; campos suportados, status de projeção, request ID, save bloqueado até identidade confirmada. Testes Angular validam endpoint/ETag, campos e bloqueio. |
+| Anti-loop/echo e chamada de execução Meta | Fail-closed | `capabilities.executable=false`; nenhum sender/adaptador Meta foi adicionado. Não há prova local ou externa de deduplicação de echo, webhook ou efeitos. |
+
+## Not confirmed — regras externas
+
+- Os identificadores `PrivateWelcomeMessage`, `CommentPublicReply` e `CommentPrivateReply` vêm do escopo da issue. Não foi confirmado que sejam nomes de configuração suportados pela API da Meta, nem que um valor salvo localmente possa ser projetado para um ativo Meta.
+- A API/conta/permissões exatas e limites de cada tipo de resposta dependem do produto e do fluxo de autorização Meta. A documentação oficial de Private Replies não pôde ser aberta durante esta execução (HTTP 429). Nenhuma janela de envio, quantidade por comentário ou permissão é assumida pelo código.
+- Não foi confirmado se a saudação privada do WhatsApp possui uma configuração pública correspondente na API Cloud ou se é apenas uma opção gerida no produto. A funcionalidade permanece armazenamento local.
+- O handler de `OUTBOX_DELIVER` não configura mensagens na Meta. Não há executor de automação, prova de echo-loop, sincronização empresarial ou confirmação de efeito; esses critérios permanecem bloqueados por integração externa futura. Não houve chamadas reais, envio de mensagens, uso de token Meta ou acesso a dados de cliente.
+- A identidade empresarial do portal permanece não confirmada pela sessão atual. O editor pode consultar a configuração, mas não habilita a gravação na UI até que a evidência M1.08 seja fornecida.
+
+## Banco e harness
+
+A migração candidata usa schema version `21`, ID `202611110021-asset-automation-config`. M5.03 e M5.07 também possuem candidatas v21; a integração da milestone deve reindexar essa versão sequencialmente e recalcular o checksum antes de consolidar migrations. O teste MariaDB executa dentro de uma transação externa e faz rollback, incluindo as linhas de auditoria append-only e o job/outbox sintéticos. O schema dedicado `ca_meta_m5_05` permanece isolado e não foi apagado.
+
+O harness `tools/m5-05/run-focused-api-test.mjs` compila o `tsconfig.build.json` com TypeScript, aplica migrations apenas quando `M5_05_AUTOMATION_TEST=1`, compila os testes para diretório temporário exclusivo, executa os dois arquivos M5.05 e remove somente esse diretório. A integração lê `API_DATABASE_*` e a CLI usa `MIGRATION_DATABASE_*`; os valores de segredo não são impressos. Dependências seguem `package-lock.json` existente, sem dependência nova. O harness Angular escreve/parseia JUnit em pasta temporária e a remove.
+
+## Evidência do candidato
+
+Worktree: `C:\Users\user\.codex\worktrees\m5-05-messages\ca_meta_app` · branch `desktop/m5-05` · base `ae867c6` · Node `v24.22.2` · npm `11.19.1` · Windows PowerShell. O junction compartilhado de dependências aponta ao checkout raiz; aliases privados de `@ca-meta/contracts` apontam ao pacote `packages/contracts` deste worktree.
+
+| Comando | Resultado |
+|---|---|
+| `npm run generate --workspace=@ca-meta/contracts` | Passou; 12 artefatos de contrato gerados. |
+| `npm run check:generated --workspace=@ca-meta/contracts` | Passou; 12 artefatos verificados. |
+| `npm run lint --workspace=@ca-meta/contracts` | Passou; quatro avisos preexistentes sobre respostas `4XX` nos handlers GET/HEAD de magic-link, sem relação com M5.05. |
+| `npm run typecheck --workspace=@ca-meta/api` | Passou para código e testes. |
+| `npm run test:m5-05 --workspace=@ca-meta/api` com `M5_05_AUTOMATION_TEST=1` | Passou; migrations aplicadas ao schema M5.05 e 7 testes focados, incluindo MariaDB real local (nenhuma fixture persistida). |
+| `npm run typecheck --workspace=@ca-meta/web` | Passou. |
+| `npm run test:m5-05 --workspace=@ca-meta/web` | Passou; 7 specs Angular focadas. |
+| `git diff --check` | Resultado será registrado no commit candidato após a composição final do diff. |
+
+O `nest` CLI não existe no `node_modules` compartilhado deste worktree, então o comando padrão `npm run build --workspace=@ca-meta/api` não pôde iniciar. Para o harness focado, TypeScript compilou o mesmo `tsconfig.build.json` diretamente; o CLI de migration foi executado no build compilado. A suíte workspace completa e qualquer validação com Meta/Suite4/PHP implantado não foram executadas.
+
+SHA candidato e resultado final de `git diff --check`: serão registrados após o commit do pacote.
diff --git a/docs/meta-v2/contracts/events/outbox-event.schema.json b/docs/meta-v2/contracts/events/outbox-event.schema.json
index 8090c7fd..ca151459 100644
--- a/docs/meta-v2/contracts/events/outbox-event.schema.json
+++ b/docs/meta-v2/contracts/events/outbox-event.schema.json
@@ -25,7 +25,7 @@
"schemaVersion": { "const": 1 },
"eventType": {
"type": "string",
- "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested"]
+ "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested", "automation.config.changed"]
},
"aggregateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"aggregateVersion": { "type": "integer", "minimum": 1 },
@@ -125,6 +125,16 @@
"templateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"operationRef": { "type": "string", "minLength": 1, "maxLength": 128 }
}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "assetRef", "configurationVersion"],
+ "properties": {
+ "kind": { "const": "AUTOMATION_CONFIG_CHANGED" },
+ "assetRef": { "type": "string", "format": "uuid" },
+ "configurationVersion": { "type": "integer", "minimum": 1 }
+ }
}
]
}
@@ -157,6 +167,10 @@
{
"if": { "properties": { "eventType": { "const": "template.creation.requested" } }, "required": ["eventType"] },
"then": { "properties": { "data": { "properties": { "kind": { "const": "TEMPLATE_CREATION_REQUESTED" } } } } }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "automation.config.changed" } }, "required": ["eventType"] },
+ "then": { "properties": { "data": { "properties": { "kind": { "const": "AUTOMATION_CONFIG_CHANGED" } } } } }
}
],
"examples": [
diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml
index 18359e37..1150539b 100644
--- a/docs/meta-v2/contracts/openapi.yaml
+++ b/docs/meta-v2/contracts/openapi.yaml
@@ -1782,10 +1782,18 @@ paths:
summary: Sanitized success example
value:
assetId: 10000000-0000-4000-8000-000000000004
- enabled: true
- replyText: Thanks for contacting our team.
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields: [privateWelcomeMessage]
+ privateWelcomeMessage: Olá! Como podemos ajudar?
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
version: 7
updatedAt: 2026-10-29T11:00:00Z
+ projectionState: PENDING
requestId: req_example_30
"400": *a11
"401": *a12
@@ -1798,11 +1806,11 @@ paths:
"429": *a19
"503": *a20
default: *a21
- put:
+ patch:
operationId: replaceAutomationConfig
tags:
- Configuration
- summary: Replace automation/reply configuration conditionally
+ summary: Update channel-supported automation/reply fields conditionally
description: Requires exact Origin, CSRF, current tenant context and If-Match from the latest
representation. Unknown fields are rejected. This changes local configuration; delivery of a
reply is a separate external effect.
@@ -1821,11 +1829,11 @@ paths:
schema:
$ref: "#/components/schemas/AutomationConfigUpdate"
examples:
- sanitized:
- summary: Sanitized request example
+ whatsapp:
+ summary: Sanitized WhatsApp phone update
value:
- enabled: false
- replyText: ""
+ version: 7
+ privateWelcomeMessage: ""
responses:
"200":
description: Sanitized success example
@@ -1843,10 +1851,18 @@ paths:
summary: Sanitized success example
value:
assetId: 10000000-0000-4000-8000-000000000004
- enabled: false
- replyText: ""
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields: [privateWelcomeMessage]
+ privateWelcomeMessage: ""
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
version: 8
updatedAt: 2026-10-29T12:02:00Z
+ projectionState: PENDING
requestId: req_example_31
"400": *a11
"401": *a12
@@ -4109,43 +4125,97 @@ components:
additionalProperties: false
required:
- assetId
- - enabled
- - replyText
+ - assetKind
+ - inheritanceMode
+ - supportedFields
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ - capabilities
- version
- updatedAt
+ - projectionState
- requestId
properties:
assetId:
type: string
- minLength: 1
- maxLength: 128
- enabled:
- type: boolean
- replyText:
+ format: uuid
+ assetKind:
+ $ref: "#/components/schemas/AutomationConfigAssetKind"
+ inheritanceMode:
type: string
+ const: ASSET_ONLY
+ description: Each resource owns independent values. No account-level default is applied.
+ supportedFields:
+ type: array
+ uniqueItems: true
+ items:
+ $ref: "#/components/schemas/AutomationConfigField"
+ privateWelcomeMessage:
+ type: [string, "null"]
maxLength: 4096
+ commentPublicReply:
+ type: [string, "null"]
+ maxLength: 4096
+ commentPrivateReply:
+ type: [string, "null"]
+ maxLength: 4096
+ capabilities:
+ type: object
+ additionalProperties: false
+ required: [canEdit, executable]
+ properties:
+ canEdit:
+ type: boolean
+ executable:
+ type: boolean
+ const: false
+ description: The API stores settings only; message execution is a separate fail-closed capability.
version:
type: integer
- minimum: 1
+ minimum: 0
updatedAt:
- type: string
+ type: [string, "null"]
format: date-time
+ projectionState:
+ $ref: "#/components/schemas/AutomationConfigProjectionState"
requestId:
type: string
minLength: 1
maxLength: 128
+ AutomationConfigAssetKind:
+ type: string
+ enum: [WHATSAPP_PHONE_NUMBER, FACEBOOK_PAGE, INSTAGRAM_BUSINESS_ACCOUNT, INSTAGRAM_CREATOR_ACCOUNT]
+ AutomationConfigField:
+ type: string
+ enum: [privateWelcomeMessage, commentPublicReply, commentPrivateReply]
+ AutomationConfigProjectionState:
+ type: string
+ enum: [UNKNOWN, PENDING, SYNCED, STALE, FAILED]
AutomationConfigUpdate:
type: object
additionalProperties: false
- required:
- - enabled
- - replyText
+ minProperties: 2
+ required: [version]
properties:
- enabled:
- type: boolean
- replyText:
+ version:
+ type: integer
+ minimum: 0
+ privateWelcomeMessage:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
+ commentPublicReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
+ commentPrivateReply:
type: string
maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]*$"
ReplyRequest:
type: object
additionalProperties: false
diff --git a/packages/contracts/events/outbox-event.schema.json b/packages/contracts/events/outbox-event.schema.json
index 8090c7fd..ca151459 100644
--- a/packages/contracts/events/outbox-event.schema.json
+++ b/packages/contracts/events/outbox-event.schema.json
@@ -25,7 +25,7 @@
"schemaVersion": { "const": 1 },
"eventType": {
"type": "string",
- "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested"]
+ "enum": ["onboarding.changed", "connection.changed", "asset.refresh.requested", "reply.requested", "notification.changed", "asset.drift.detected", "template.creation.requested", "automation.config.changed"]
},
"aggregateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"aggregateVersion": { "type": "integer", "minimum": 1 },
@@ -125,6 +125,16 @@
"templateRef": { "type": "string", "minLength": 1, "maxLength": 128 },
"operationRef": { "type": "string", "minLength": 1, "maxLength": 128 }
}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "assetRef", "configurationVersion"],
+ "properties": {
+ "kind": { "const": "AUTOMATION_CONFIG_CHANGED" },
+ "assetRef": { "type": "string", "format": "uuid" },
+ "configurationVersion": { "type": "integer", "minimum": 1 }
+ }
}
]
}
@@ -157,6 +167,10 @@
{
"if": { "properties": { "eventType": { "const": "template.creation.requested" } }, "required": ["eventType"] },
"then": { "properties": { "data": { "properties": { "kind": { "const": "TEMPLATE_CREATION_REQUESTED" } } } } }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "automation.config.changed" } }, "required": ["eventType"] },
+ "then": { "properties": { "data": { "properties": { "kind": { "const": "AUTOMATION_CONFIG_CHANGED" } } } } }
}
],
"examples": [
diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml
index 189bf6bc..0f80dc2f 100644
--- a/packages/contracts/openapi.yaml
+++ b/packages/contracts/openapi.yaml
@@ -1921,10 +1921,19 @@ paths:
summary: Sanitized success example
value:
assetId: 10000000-0000-4000-8000-000000000004
- enabled: true
- replyText: Thanks for contacting our team.
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields:
+ - privateWelcomeMessage
+ privateWelcomeMessage: Olá! Como podemos ajudar?
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
version: 7
updatedAt: 2026-10-29T11:00:00Z
+ projectionState: PENDING
requestId: req_example_30
"400": *a11
"401": *a12
@@ -1937,11 +1946,11 @@ paths:
"429": *a19
"503": *a20
default: *a21
- put:
+ patch:
operationId: replaceAutomationConfig
tags:
- Configuration
- summary: Replace automation/reply configuration conditionally
+ summary: Update channel-supported automation/reply fields conditionally
description: Requires exact Origin, CSRF, current tenant context and If-Match
from the latest representation. Unknown fields are rejected. This
changes local configuration; delivery of a reply is a separate external
@@ -1961,11 +1970,11 @@ paths:
schema:
$ref: "#/components/schemas/AutomationConfigUpdate"
examples:
- sanitized:
- summary: Sanitized request example
+ whatsapp:
+ summary: Sanitized WhatsApp phone update
value:
- enabled: false
- replyText: ""
+ version: 7
+ privateWelcomeMessage: ""
responses:
"200":
description: Sanitized success example
@@ -1983,10 +1992,19 @@ paths:
summary: Sanitized success example
value:
assetId: 10000000-0000-4000-8000-000000000004
- enabled: false
- replyText: ""
+ assetKind: WHATSAPP_PHONE_NUMBER
+ inheritanceMode: ASSET_ONLY
+ supportedFields:
+ - privateWelcomeMessage
+ privateWelcomeMessage: ""
+ commentPublicReply: null
+ commentPrivateReply: null
+ capabilities:
+ canEdit: true
+ executable: false
version: 8
updatedAt: 2026-10-29T12:02:00Z
+ projectionState: PENDING
requestId: req_example_31
"400": *a11
"401": *a12
@@ -4180,43 +4198,122 @@ components:
additionalProperties: false
required:
- assetId
- - enabled
- - replyText
+ - assetKind
+ - inheritanceMode
+ - supportedFields
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ - capabilities
- version
- updatedAt
+ - projectionState
- requestId
properties:
assetId:
type: string
- minLength: 1
- maxLength: 128
- enabled:
- type: boolean
- replyText:
- type: string
+ format: uuid
+ assetKind:
+ $ref: "#/components/schemas/AutomationConfigAssetKind"
+ inheritanceMode:
+ type: string
+ const: ASSET_ONLY
+ description: Each resource owns independent values. No account-level default is
+ applied.
+ supportedFields:
+ type: array
+ uniqueItems: true
+ items:
+ $ref: "#/components/schemas/AutomationConfigField"
+ privateWelcomeMessage:
+ type:
+ - string
+ - "null"
maxLength: 4096
+ commentPublicReply:
+ type:
+ - string
+ - "null"
+ maxLength: 4096
+ commentPrivateReply:
+ type:
+ - string
+ - "null"
+ maxLength: 4096
+ capabilities:
+ type: object
+ additionalProperties: false
+ required:
+ - canEdit
+ - executable
+ properties:
+ canEdit:
+ type: boolean
+ executable:
+ type: boolean
+ const: false
+ description: The API stores settings only; message execution is a separate
+ fail-closed capability.
version:
type: integer
- minimum: 1
+ minimum: 0
updatedAt:
- type: string
+ type:
+ - string
+ - "null"
format: date-time
+ projectionState:
+ $ref: "#/components/schemas/AutomationConfigProjectionState"
requestId:
type: string
minLength: 1
maxLength: 128
+ AutomationConfigAssetKind:
+ type: string
+ enum:
+ - WHATSAPP_PHONE_NUMBER
+ - FACEBOOK_PAGE
+ - INSTAGRAM_BUSINESS_ACCOUNT
+ - INSTAGRAM_CREATOR_ACCOUNT
+ AutomationConfigField:
+ type: string
+ enum:
+ - privateWelcomeMessage
+ - commentPublicReply
+ - commentPrivateReply
+ AutomationConfigProjectionState:
+ type: string
+ enum:
+ - UNKNOWN
+ - PENDING
+ - SYNCED
+ - STALE
+ - FAILED
AutomationConfigUpdate:
type: object
additionalProperties: false
+ minProperties: 2
required:
- - enabled
- - replyText
+ - version
properties:
- enabled:
- type: boolean
- replyText:
+ version:
+ type: integer
+ minimum: 0
+ privateWelcomeMessage:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
+ commentPublicReply:
+ type: string
+ maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
+ commentPrivateReply:
type: string
maxLength: 4096
+ description: Empty text clears the local value for this asset.
+ pattern: ^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$
ReplyRequest:
type: object
additionalProperties: false
diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts
index 53390b08..2d7297c7 100644
--- a/packages/contracts/src/generated/openapi.d.ts
+++ b/packages/contracts/src/generated/openapi.d.ts
@@ -569,16 +569,16 @@ export interface paths {
* @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
*/
readonly get: operations["getAutomationConfig"];
- /**
- * Replace automation/reply configuration conditionally
- * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
- */
- readonly put: operations["replaceAutomationConfig"];
+ readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
- readonly patch?: never;
+ /**
+ * Update channel-supported automation/reply fields conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly patch: operations["replaceAutomationConfig"];
readonly trace?: never;
};
readonly "/api/v1/assets/{assetId}/replies": {
@@ -1187,17 +1187,46 @@ export interface components {
readonly requestId: string;
};
readonly AutomationConfig: {
+ /** Format: uuid */
readonly assetId: string;
- readonly enabled: boolean;
- readonly replyText: string;
+ readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
+ /**
+ * @description Each resource owns independent values. No account-level default is applied.
+ * @constant
+ */
+ readonly inheritanceMode: "ASSET_ONLY";
+ readonly supportedFields: readonly components["schemas"]["AutomationConfigField"][];
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly capabilities: {
+ readonly canEdit: boolean;
+ /**
+ * @description The API stores settings only; message execution is a separate fail-closed capability.
+ * @constant
+ */
+ readonly executable: false;
+ };
readonly version: number;
/** Format: date-time */
- readonly updatedAt: string;
+ readonly updatedAt: string | null;
+ readonly projectionState: components["schemas"]["AutomationConfigProjectionState"];
readonly requestId: string;
};
+ /** @enum {string} */
+ readonly AutomationConfigAssetKind: "WHATSAPP_PHONE_NUMBER" | "FACEBOOK_PAGE" | "INSTAGRAM_BUSINESS_ACCOUNT" | "INSTAGRAM_CREATOR_ACCOUNT";
+ /** @enum {string} */
+ readonly AutomationConfigField: "privateWelcomeMessage" | "commentPublicReply" | "commentPrivateReply";
+ /** @enum {string} */
+ readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
- readonly enabled: boolean;
- readonly replyText: string;
+ readonly version: number;
+ /** @description Empty text clears the local value for this asset. */
+ readonly privateWelcomeMessage?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPublicReply?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPrivateReply?: string;
};
readonly ReplyRequest: {
readonly inReplyToRef: string;
diff --git a/tools/m5-05/run-focused-api-test.mjs b/tools/m5-05/run-focused-api-test.mjs
new file mode 100644
index 00000000..ccd2fb11
--- /dev/null
+++ b/tools/m5-05/run-focused-api-test.mjs
@@ -0,0 +1,66 @@
+import { mkdir, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+if (Number(process.versions.node.split('.')[0]) !== 24) {
+ throw new Error(`M5.05 validation requires the repository's Node 24 runtime; received ${process.version}.`);
+}
+
+const npmCli = process.env.npm_execpath;
+if (!npmCli) throw new Error('Run this focused harness through the npm workspace script.');
+const apiRoot = join(repositoryRoot, 'apps', 'api');
+const compiler = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc');
+const outputRoot = join(apiRoot, '.test-runs');
+const temporaryOutput = join(outputRoot, `m5-05-${process.pid}-${Date.now()}`);
+const safePrefix = `${resolve(outputRoot)}${sep}`;
+
+function run(args) {
+ const result = spawnSync(process.execPath, args, {
+ cwd: repositoryRoot,
+ stdio: 'inherit',
+ windowsHide: true,
+ env: process.env,
+ });
+ if (result.error) throw result.error;
+ return result.status === 0;
+}
+
+try {
+ await mkdir(outputRoot, { recursive: true });
+ await mkdir(temporaryOutput, { recursive: false });
+ process.stdout.write(`M5.05 focused API tests — Node ${process.version}\n`);
+
+ if (!run([compiler, '--project', join(apiRoot, 'tsconfig.build.json'), '--pretty', 'false'])) {
+ process.exitCode = 1;
+ } else {
+ if (process.env.M5_05_AUTOMATION_TEST === '1' &&
+ !run([join(apiRoot, 'dist', 'database', 'migrations', 'cli.js'), 'deploy'])) {
+ process.exitCode = 1;
+ }
+
+ if (process.exitCode !== 1 && !run([
+ join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'),
+ '--project', join(apiRoot, 'tsconfig.test.json'),
+ '--outDir', temporaryOutput,
+ '--pretty', 'false',
+ ])) {
+ process.exitCode = 1;
+ }
+
+ if (process.exitCode !== 1 && !run([
+ '--test', '--test-concurrency=1',
+ join(temporaryOutput, 'test', 'm5-05-automation-config.spec.js'),
+ join(temporaryOutput, 'test', 'm5-05-automation-config.integration.spec.js'),
+ ])) {
+ process.exitCode = 1;
+ }
+ }
+} finally {
+ const resolvedOutput = resolve(temporaryOutput);
+ if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-05-`)) {
+ throw new Error('M5.05 harness refused to remove an output directory outside its private workspace.');
+ }
+ await rm(resolvedOutput, { recursive: true, force: true });
+}
diff --git a/tools/m5-05/run-focused-web-tests.mjs b/tools/m5-05/run-focused-web-tests.mjs
new file mode 100644
index 00000000..ac12ba51
--- /dev/null
+++ b/tools/m5-05/run-focused-web-tests.mjs
@@ -0,0 +1,59 @@
+import { mkdir, readFile, rm } from 'node:fs/promises';
+import { spawnSync } from 'node:child_process';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+if (Number(process.versions.node.split('.')[0]) !== 24) {
+ throw new Error(`M5.05 validation requires the repository's Node 24 runtime; received ${process.version}.`);
+}
+const cli = join(repositoryRoot, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
+const outputRoot = join(repositoryRoot, 'apps', 'web', '.test-runs');
+await mkdir(outputRoot, { recursive: true });
+const temporaryOutput = join(outputRoot, `m5-05-${process.pid}-${Date.now()}`);
+await mkdir(temporaryOutput, { recursive: false });
+const safePrefix = `${resolve(outputRoot)}${sep}`;
+
+function run(args) {
+ const result = spawnSync(process.execPath, [cli, ...args], {
+ cwd: join(repositoryRoot, 'apps', 'web'),
+ stdio: 'inherit',
+ windowsHide: true,
+ });
+ if (result.error) throw result.error;
+ return result.status === 0;
+}
+
+try {
+ process.stdout.write(`M5.05 focused Angular specs — Node ${process.version}\n`);
+ const outputFile = join(temporaryOutput, 'web.junit.xml');
+ const args = [
+ 'test', 'meta-integration', '--watch=false', `--output-file=${outputFile}`,
+ '--include=src/app/core/portal/automation-config.service.spec.ts',
+ '--include=src/app/features/portal/asset-automation-config.component.spec.ts',
+ '--include=src/app/features/portal/connection-record-detail.component.spec.ts',
+ ];
+ const commandSucceeded = run(args);
+ try {
+ const report = await readFile(outputFile, 'utf8');
+ const suites = [...report.matchAll(/
]*)>/g)];
+ const total = (attribute) => suites.reduce((sum, suite) => sum + Number(suite[1].match(new RegExp(`\\b${attribute}="(\\d+)"`))?.[1] ?? 0), 0);
+ const tests = total('tests');
+ const failures = total('failures') + total('errors');
+ if (!commandSucceeded || tests === 0 || failures !== 0) {
+ process.stderr.write(`M5.05 Angular JUnit result: ${tests} test(s), ${failures} failure(s).\n`);
+ process.exitCode = 1;
+ } else {
+ process.stdout.write(`M5.05 Angular specs: ${tests} test(s), ${failures} failure(s).\n`);
+ }
+ } catch (error) {
+ if (commandSucceeded) throw error;
+ process.exitCode = 1;
+ }
+} finally {
+ const resolvedOutput = resolve(temporaryOutput);
+ if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-05-`)) {
+ throw new Error('M5.05 harness refused to remove an output directory outside its private workspace.');
+ }
+ await rm(resolvedOutput, { recursive: true, force: true });
+}
From 7118511bacc214f996ef6fcc4ff70ff724b3030e Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 16:37:43 -0300
Subject: [PATCH 02/13] docs(m5-05): record candidate evidence
---
docs/delivery/M5/M5.05.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index 939f09f0..c67664ed 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -52,8 +52,8 @@ Worktree: `C:\Users\user\.codex\worktrees\m5-05-messages\ca_meta_app` · branch
| `npm run test:m5-05 --workspace=@ca-meta/api` com `M5_05_AUTOMATION_TEST=1` | Passou; migrations aplicadas ao schema M5.05 e 7 testes focados, incluindo MariaDB real local (nenhuma fixture persistida). |
| `npm run typecheck --workspace=@ca-meta/web` | Passou. |
| `npm run test:m5-05 --workspace=@ca-meta/web` | Passou; 7 specs Angular focadas. |
-| `git diff --check` | Resultado será registrado no commit candidato após a composição final do diff. |
+| `git diff-tree --check HEAD^ HEAD` | Passou no commit candidato. |
O `nest` CLI não existe no `node_modules` compartilhado deste worktree, então o comando padrão `npm run build --workspace=@ca-meta/api` não pôde iniciar. Para o harness focado, TypeScript compilou o mesmo `tsconfig.build.json` diretamente; o CLI de migration foi executado no build compilado. A suíte workspace completa e qualquer validação com Meta/Suite4/PHP implantado não foram executadas.
-SHA candidato e resultado final de `git diff --check`: serão registrados após o commit do pacote.
+SHA candidato testado: `a9871712525f6d0363616f3a0c0a3c121901e99b`. O commit seguinte, se houver, apenas ajusta esta evidência do pacote.
From 9463b1e5ffd1a0b1063853c1446540fa55a3c91d Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 17:10:13 -0300
Subject: [PATCH 03/13] fix(m5-05): ignore automation saves from old tenant
context
---
.../asset-automation-config.component.spec.ts | 78 ++++++++++++++++++-
.../asset-automation-config.component.ts | 15 +++-
2 files changed, 88 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
index 133d88f5..fab2fa9c 100644
--- a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
@@ -1,8 +1,8 @@
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { TestBed } from '@angular/core/testing';
-import { NEVER } from 'rxjs';
+import { NEVER, Subject, of } from 'rxjs';
import type { components } from '@ca-meta/contracts';
-import { AutomationConfigService } from '../../core/portal/automation-config.service';
+import { AutomationConfigService, type VersionedAutomationConfig } from '../../core/portal/automation-config.service';
import { PortalContextStore } from '../../core/portal/portal-context.store';
import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
import { AssetAutomationConfigComponent } from './asset-automation-config.component';
@@ -52,4 +52,76 @@ describe('AssetAutomationConfigComponent', () => {
expect(settings.update).not.toHaveBeenCalled();
fixture.destroy();
});
+
+ it('does not apply a delayed save response after the active tenant context changes', async () => {
+ const assetId = '00000000-0000-4000-8000-000000000051';
+ const config = (privateWelcomeMessage: string, version: number): VersionedAutomationConfig => ({
+ etag: `"automation-v${version}"`,
+ config: {
+ assetId,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: `req-synthetic-automation-ui-${version}`,
+ },
+ });
+ const oldContextConfig = config('Mensagem do tenant anterior', 2);
+ const newContextConfig = config('Mensagem do tenant atual', 3);
+ const contextChanged$ = new Subject();
+ let contextRef = 'synthetic-tenant-context-reference-111';
+ const context = {
+ contextChanged$: contextChanged$.asObservable(),
+ get contextRef() { return contextRef; },
+ };
+ let resolveUpdate!: (value: VersionedAutomationConfig) => void;
+ const settings = {
+ read: vi.fn().mockResolvedValueOnce(config('Mensagem original', 1)).mockResolvedValueOnce(newContextConfig),
+ update: vi.fn(() => new Promise((resolve) => { resolveUpdate = resolve; })),
+ };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: assetId })) } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: context },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: true,
+ tenantDisplayName: 'Empresa sintética',
+ source: 'server-session',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const component = fixture.componentInstance;
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem original');
+ component.form.privateWelcomeMessage = 'Mensagem enviada pelo tenant anterior';
+ const save = component.save();
+ expect(component.isSaving).toBe(true);
+
+ contextRef = 'synthetic-tenant-context-reference-222';
+ contextChanged$.next();
+ await fixture.whenStable();
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.isSaving).toBe(false);
+
+ resolveUpdate(oldContextConfig);
+ await save;
+ expect(component.config?.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Mensagem do tenant atual');
+ expect(component.savedMessage).toBe('');
+ expect(component.errorMessage).toBe('');
+ expect(component.isSaving).toBe(false);
+ fixture.destroy();
+ });
});
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.ts b/apps/web/src/app/features/portal/asset-automation-config.component.ts
index 8cf91e27..3ebf650e 100644
--- a/apps/web/src/app/features/portal/asset-automation-config.component.ts
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.ts
@@ -84,6 +84,10 @@ export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
async save(): Promise {
if (!this.config || !this.canSave || !this.etag) return;
+ const generation = this.generation;
+ const assetId = this.assetId;
+ const contextRef = this.context.contextRef;
+ if (!contextRef) return;
const update = {
version: this.config.version,
...(this.supports('privateWelcomeMessage') && this.form.privateWelcomeMessage !== (this.config.privateWelcomeMessage ?? '')
@@ -99,16 +103,18 @@ export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
this.savedMessage = '';
this.errorRequestId = null;
try {
- const next = await this.settings.update(this.assetId, update, this.etag);
+ const next = await this.settings.update(assetId, update, this.etag);
+ if (!this.isCurrentSave(generation, assetId, contextRef)) return;
this.setConfig(next);
this.savedMessage = 'Configuração salva no catálogo central. A sincronização empresarial está pendente quando indicada abaixo.';
} catch (error: unknown) {
+ if (!this.isCurrentSave(generation, assetId, contextRef)) return;
this.errorMessage = error instanceof HttpErrorResponse && error.status === 412
? 'A configuração mudou em outra sessão. Atualize e revise os valores antes de salvar.'
: 'Não foi possível salvar a configuração. Nenhuma mensagem foi enviada.';
this.errorRequestId = safeRequestId(error);
} finally {
- this.isSaving = false;
+ if (this.isCurrentSave(generation, assetId, contextRef)) this.isSaving = false;
}
}
@@ -116,6 +122,7 @@ export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
const generation = ++this.generation;
this.config = null;
this.isLoading = true;
+ this.isSaving = false;
this.errorMessage = '';
this.errorRequestId = null;
this.savedMessage = '';
@@ -146,4 +153,8 @@ export class AssetAutomationConfigComponent implements OnInit, OnDestroy {
commentPrivateReply: value.config.commentPrivateReply ?? '',
};
}
+
+ private isCurrentSave(generation: number, assetId: string, contextRef: string): boolean {
+ return generation === this.generation && assetId === this.assetId && contextRef === this.context.contextRef;
+ }
}
From 22134cf87b2db0fb1252cfa65755807dfa40c5e8 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 17:10:36 -0300
Subject: [PATCH 04/13] docs(m5-05): record tenant context race fix
---
docs/delivery/M5/M5.05.md | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index c67664ed..739bbb19 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -56,4 +56,10 @@ Worktree: `C:\Users\user\.codex\worktrees\m5-05-messages\ca_meta_app` · branch
O `nest` CLI não existe no `node_modules` compartilhado deste worktree, então o comando padrão `npm run build --workspace=@ca-meta/api` não pôde iniciar. Para o harness focado, TypeScript compilou o mesmo `tsconfig.build.json` diretamente; o CLI de migration foi executado no build compilado. A suíte workspace completa e qualquer validação com Meta/Suite4/PHP implantado não foram executadas.
-SHA candidato testado: `a9871712525f6d0363616f3a0c0a3c121901e99b`. O commit seguinte, se houver, apenas ajusta esta evidência do pacote.
+SHA candidato funcional original testado: `a9871712525f6d0363616f3a0c0a3c121901e99b`.
+
+## Correção focada de troca de contexto
+
+Uma revisão independente apontou que uma resposta PATCH atrasada poderia substituir o estado exibido após a troca de tenant. `save()` agora captura a geração, o UUID interno do ativo e o contexto opaco do servidor; sucesso, erro e `finally` só alteram a tela se os três ainda forem atuais. O carregamento do novo contexto também libera `isSaving`. A regressão usa PATCH diferido, muda o contexto, carrega os valores do novo tenant e confirma que a resposta antiga não os altera.
+
+No SHA `32f31f5cca0a3e6c7a9b3a3d498439d4082f7832`, `tools/m5-05/run-focused-web-tests.mjs` via Node 24.22.2 passou 8/8 specs, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. A correção não altera API, contrato ou migração; gates não relacionados não foram repetidos.
From e3feb1005a8e7c57c5647d3bf1654bf13398ffaf Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 17:14:15 -0300
Subject: [PATCH 05/13] test(m5-05): cover stale save rejection after tenant
switch
---
.../asset-automation-config.component.spec.ts | 80 +++++++++++++++++++
1 file changed, 80 insertions(+)
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
index fab2fa9c..27f13330 100644
--- a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
@@ -124,4 +124,84 @@ describe('AssetAutomationConfigComponent', () => {
expect(component.isSaving).toBe(false);
fixture.destroy();
});
+
+ it('ignores a delayed save rejection while a save for the new tenant is in progress', async () => {
+ const assetId = '00000000-0000-4000-8000-000000000052';
+ const config = (privateWelcomeMessage: string, version: number): VersionedAutomationConfig => ({
+ etag: `"automation-v${version}"`,
+ config: {
+ assetId,
+ assetKind: 'WHATSAPP_PHONE_NUMBER',
+ inheritanceMode: 'ASSET_ONLY',
+ supportedFields: ['privateWelcomeMessage'],
+ privateWelcomeMessage,
+ commentPublicReply: null,
+ commentPrivateReply: null,
+ capabilities: { canEdit: true, executable: false },
+ version,
+ updatedAt: null,
+ projectionState: 'UNKNOWN',
+ requestId: `req-synthetic-automation-reject-${version}`,
+ },
+ });
+ const contextChanged$ = new Subject();
+ let contextRef = 'synthetic-tenant-context-reference-333';
+ const context = {
+ contextChanged$: contextChanged$.asObservable(),
+ get contextRef() { return contextRef; },
+ };
+ const pendingUpdates: Array<{
+ readonly resolve: (value: VersionedAutomationConfig) => void;
+ readonly reject: (reason: unknown) => void;
+ }> = [];
+ const settings = {
+ read: vi.fn().mockResolvedValueOnce(config('Tenant inicial', 1)).mockResolvedValueOnce(config('Tenant atual', 2)),
+ update: vi.fn(() => new Promise((resolve, reject) => pendingUpdates.push({ resolve, reject }))),
+ };
+ await TestBed.configureTestingModule({
+ imports: [AssetAutomationConfigComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: assetId })) } },
+ { provide: AutomationConfigService, useValue: settings },
+ { provide: PortalContextStore, useValue: context },
+ { provide: PortalTenantIdentityService, useValue: { current: {
+ confirmed: true,
+ tenantDisplayName: 'Empresa sintética',
+ source: 'server-session',
+ } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetAutomationConfigComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const component = fixture.componentInstance;
+ component.form.privateWelcomeMessage = 'Alteração do tenant anterior';
+ const oldSave = component.save();
+ expect(component.isSaving).toBe(true);
+
+ contextRef = 'synthetic-tenant-context-reference-444';
+ contextChanged$.next();
+ await fixture.whenStable();
+ expect(component.config?.privateWelcomeMessage).toBe('Tenant atual');
+ expect(component.isSaving).toBe(false);
+
+ component.form.privateWelcomeMessage = 'Alteração do tenant atual';
+ const currentSave = component.save();
+ expect(component.isSaving).toBe(true);
+ pendingUpdates[0]!.reject(new Error('synthetic late old-tenant failure'));
+ await oldSave;
+ expect(component.errorMessage).toBe('');
+ expect(component.errorRequestId).toBeNull();
+ expect(component.savedMessage).toBe('');
+ expect(component.isSaving).toBe(true);
+
+ pendingUpdates[1]!.resolve(config('Salvo no tenant atual', 3));
+ await currentSave;
+ expect(component.config?.privateWelcomeMessage).toBe('Salvo no tenant atual');
+ expect(component.form.privateWelcomeMessage).toBe('Salvo no tenant atual');
+ expect(component.savedMessage).toContain('Configuração salva');
+ expect(component.isSaving).toBe(false);
+ fixture.destroy();
+ });
});
From c114b0031bc354d35973d12cacd8caa07fbd871a Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Wed, 11 Nov 2026 17:14:41 -0300
Subject: [PATCH 06/13] docs(m5-05): record stale rejection regression
---
docs/delivery/M5/M5.05.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index 739bbb19..ed7ca4a1 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -60,6 +60,6 @@ SHA candidato funcional original testado: `a9871712525f6d0363616f3a0c0a3c121901e
## Correção focada de troca de contexto
-Uma revisão independente apontou que uma resposta PATCH atrasada poderia substituir o estado exibido após a troca de tenant. `save()` agora captura a geração, o UUID interno do ativo e o contexto opaco do servidor; sucesso, erro e `finally` só alteram a tela se os três ainda forem atuais. O carregamento do novo contexto também libera `isSaving`. A regressão usa PATCH diferido, muda o contexto, carrega os valores do novo tenant e confirma que a resposta antiga não os altera.
+Uma revisão independente apontou que uma resposta PATCH atrasada poderia substituir o estado exibido após a troca de tenant. `save()` agora captura a geração, o UUID interno do ativo e o contexto opaco do servidor; sucesso, erro e `finally` só alteram a tela se os três ainda forem atuais. O carregamento do novo contexto também libera `isSaving`. As regressões usam PATCH diferido, mudam o contexto e carregam os valores do novo tenant; tanto a resposta antiga resolvida como a rejeitada são ignoradas, inclusive enquanto um novo save está ativo.
-No SHA `32f31f5cca0a3e6c7a9b3a3d498439d4082f7832`, `tools/m5-05/run-focused-web-tests.mjs` via Node 24.22.2 passou 8/8 specs, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. A correção não altera API, contrato ou migração; gates não relacionados não foram repetidos.
+No SHA `7369c9a1174b829df58a538cd237c3168dffeaf2`, `tools/m5-05/run-focused-web-tests.mjs` via Node 24.22.2 passou 9/9 specs, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. A revisão final também passou; o commit acrescenta somente a regressão da rejeição atrasada ao código de produção validado em `32f31f5cca0a3e6c7a9b3a3d498439d4082f7832`. A correção não altera API, contrato ou migração; gates não relacionados não foram repetidos.
From c4773ba1016382071a5319cae957ead99f083cb0 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 19:05:48 -0300
Subject: [PATCH 07/13] test(m5-05): record rebase validation and migration v23
---
apps/api/test/migration-unit.spec.ts | 10 +++--
docs/delivery/M5/M5.05.md | 10 ++++-
tools/m1-07/src/generated/openapi.d.ts | 51 ++++++++++++++++++++------
3 files changed, 56 insertions(+), 15 deletions(-)
diff --git a/apps/api/test/migration-unit.spec.ts b/apps/api/test/migration-unit.spec.ts
index 83494606..fe2461ce 100644
--- a/apps/api/test/migration-unit.spec.ts
+++ b/apps/api/test/migration-unit.spec.ts
@@ -21,6 +21,7 @@ import { ASSET_OBSERVATIONS_MIGRATION, ASSET_OBSERVATION_TABLES } from '../src/d
import { CONNECTION_LIFECYCLE_MIGRATION } from '../src/database/migrations/connection-lifecycle.js';
import { INTERNAL_ASSET_OPERATIONS_MIGRATION } from '../src/database/migrations/internal-asset-operations.js';
import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from '../src/database/migrations/onboarding-coexistence-lock.js';
+import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from '../src/database/migrations/automation-config.js';
import { assertMigrationGrantRecords, CoexistenceLockDeleteGrantRequiredError, safeMigrationError } from '../src/database/migrations/migration-runner.js';
const validEnvironment: NodeJS.ProcessEnv = {
@@ -104,9 +105,9 @@ test('migration grants require the lock-trigger definer privilege only on its ex
test('versioned migrations carry deterministic checksums and explicit lifecycle phases', () => {
const schemaVersions = MIGRATIONS.map((migration) => migration.schemaVersion);
- assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]);
- assert.equal(schemaVersions.at(-1), 22);
- assert.equal(MIGRATIONS.length, 22);
+ assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]);
+ assert.equal(schemaVersions.at(-1), 23);
+ assert.equal(MIGRATIONS.length, 23);
for (const migration of MIGRATIONS) assert.match(migration.checksum, /^[a-f0-9]{64}$/);
assert.deepEqual(MIGRATIONS[0]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'expand']);
assert.deepEqual(MIGRATIONS[1]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'backfill', 'validate']);
@@ -152,6 +153,8 @@ test('versioned migrations carry deterministic checksums and explicit lifecycle
assert.equal(INTERNAL_ASSET_OPERATIONS_MIGRATION.schemaVersion, 21);
assert.equal(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id, '202611120022-onboarding-coexistence-serialization');
assert.equal(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.schemaVersion, 22);
+ assert.equal(AUTOMATION_CONFIG_MIGRATION.id, '202611120023-asset-automation-config');
+ assert.equal(AUTOMATION_CONFIG_MIGRATION.schemaVersion, 23);
const coexistenceLockTable = ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.find((step) => step.id === 'create-onboarding-coexistence-locks');
assert.ok(coexistenceLockTable && 'sql' in coexistenceLockTable);
assert.match(coexistenceLockTable.sql, /PRIMARY KEY \(tenant_id\)/);
@@ -161,6 +164,7 @@ test('versioned migrations carry deterministic checksums and explicit lifecycle
assert.ok(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.some((step) => 'runBatch' in step && step.id === 'backfill-active-onboarding-coexistence-locks'));
assert.ok(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.some((step) => step.id === 'release-coexistence-lock-on-terminal-onboarding-status'));
assert.ok(ONBOARDING_COEXISTENCE_LOCK_TABLES.every((table) => DOMAIN_TABLES.includes(table as typeof DOMAIN_TABLES[number])));
+ assert.ok(AUTOMATION_CONFIG_TABLES.every((table) => DOMAIN_TABLES.includes(table as typeof DOMAIN_TABLES[number])));
assert.deepEqual(ASSET_OBSERVATIONS_MIGRATION.steps.map((step) => step.id), [
'create-meta-asset-observations', 'asset-observation-no-update', 'asset-observation-no-delete',
]);
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index ed7ca4a1..c597978d 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -35,7 +35,7 @@ O valor atual, o histórico append-only de metadados, o evento `automation.confi
## Banco e harness
-A migração candidata usa schema version `21`, ID `202611110021-asset-automation-config`. M5.03 e M5.07 também possuem candidatas v21; a integração da milestone deve reindexar essa versão sequencialmente e recalcular o checksum antes de consolidar migrations. O teste MariaDB executa dentro de uma transação externa e faz rollback, incluindo as linhas de auditoria append-only e o job/outbox sintéticos. O schema dedicado `ca_meta_m5_05` permanece isolado e não foi apagado.
+A migração candidata reindexada usa schema version `23`, ID `202611120023-asset-automation-config`. Na base M5.04 integrada, M5.07 ocupa v21 e M5.03 ocupa v22; a migração M5.05 vem depois delas e seu checksum é recalculado a partir da definição. A evidência histórica v21 descrita abaixo pertence à base candidata anterior, antes desta recomposição. O teste MariaDB executa dentro de uma transação externa e faz rollback, incluindo as linhas de auditoria append-only e o job/outbox sintéticos. O schema dedicado `ca_meta_m5_05` permanece isolado e não foi apagado.
O harness `tools/m5-05/run-focused-api-test.mjs` compila o `tsconfig.build.json` com TypeScript, aplica migrations apenas quando `M5_05_AUTOMATION_TEST=1`, compila os testes para diretório temporário exclusivo, executa os dois arquivos M5.05 e remove somente esse diretório. A integração lê `API_DATABASE_*` e a CLI usa `MIGRATION_DATABASE_*`; os valores de segredo não são impressos. Dependências seguem `package-lock.json` existente, sem dependência nova. O harness Angular escreve/parseia JUnit em pasta temporária e a remove.
@@ -63,3 +63,11 @@ SHA candidato funcional original testado: `a9871712525f6d0363616f3a0c0a3c121901e
Uma revisão independente apontou que uma resposta PATCH atrasada poderia substituir o estado exibido após a troca de tenant. `save()` agora captura a geração, o UUID interno do ativo e o contexto opaco do servidor; sucesso, erro e `finally` só alteram a tela se os três ainda forem atuais. O carregamento do novo contexto também libera `isSaving`. As regressões usam PATCH diferido, mudam o contexto e carregam os valores do novo tenant; tanto a resposta antiga resolvida como a rejeitada são ignoradas, inclusive enquanto um novo save está ativo.
No SHA `7369c9a1174b829df58a538cd237c3168dffeaf2`, `tools/m5-05/run-focused-web-tests.mjs` via Node 24.22.2 passou 9/9 specs, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. A revisão final também passou; o commit acrescenta somente a regressão da rejeição atrasada ao código de produção validado em `32f31f5cca0a3e6c7a9b3a3d498439d4082f7832`. A correção não altera API, contrato ou migração; gates não relacionados não foram repetidos.
+
+## Recomposição sobre M5.04
+
+A branch `desktop/m5-05` foi rebaseada sobre o squash M5.04 `e73456d037d3e0d05be2ab684820bb4be660390e`, reaplicando somente os seis commits M5.05 após a base antiga `ae867c6`. Os scripts focados M5.03/M5.04 foram preservados junto dos scripts M5.05. O módulo MetaAssets registra os controllers de Templates WhatsApp e Automation Config; o registry mantém migrations v21/v22 da base e registra M5.05 como v23. O cliente gerado M1.07 foi regenerado a partir do contrato espelhado combinado.
+
+O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado para substituir seu registro v21. A validação da migration v23 com MariaDB requer schema de teste isolado e fresco; esta recomposição não reutilizou o schema persistido.
+
+No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida. A integração MariaDB com migration v23 permanece pendente de um schema de teste fresco.
diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts
index 53390b08..2d7297c7 100644
--- a/tools/m1-07/src/generated/openapi.d.ts
+++ b/tools/m1-07/src/generated/openapi.d.ts
@@ -569,16 +569,16 @@ export interface paths {
* @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
*/
readonly get: operations["getAutomationConfig"];
- /**
- * Replace automation/reply configuration conditionally
- * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
- */
- readonly put: operations["replaceAutomationConfig"];
+ readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
- readonly patch?: never;
+ /**
+ * Update channel-supported automation/reply fields conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly patch: operations["replaceAutomationConfig"];
readonly trace?: never;
};
readonly "/api/v1/assets/{assetId}/replies": {
@@ -1187,17 +1187,46 @@ export interface components {
readonly requestId: string;
};
readonly AutomationConfig: {
+ /** Format: uuid */
readonly assetId: string;
- readonly enabled: boolean;
- readonly replyText: string;
+ readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
+ /**
+ * @description Each resource owns independent values. No account-level default is applied.
+ * @constant
+ */
+ readonly inheritanceMode: "ASSET_ONLY";
+ readonly supportedFields: readonly components["schemas"]["AutomationConfigField"][];
+ readonly privateWelcomeMessage: string | null;
+ readonly commentPublicReply: string | null;
+ readonly commentPrivateReply: string | null;
+ readonly capabilities: {
+ readonly canEdit: boolean;
+ /**
+ * @description The API stores settings only; message execution is a separate fail-closed capability.
+ * @constant
+ */
+ readonly executable: false;
+ };
readonly version: number;
/** Format: date-time */
- readonly updatedAt: string;
+ readonly updatedAt: string | null;
+ readonly projectionState: components["schemas"]["AutomationConfigProjectionState"];
readonly requestId: string;
};
+ /** @enum {string} */
+ readonly AutomationConfigAssetKind: "WHATSAPP_PHONE_NUMBER" | "FACEBOOK_PAGE" | "INSTAGRAM_BUSINESS_ACCOUNT" | "INSTAGRAM_CREATOR_ACCOUNT";
+ /** @enum {string} */
+ readonly AutomationConfigField: "privateWelcomeMessage" | "commentPublicReply" | "commentPrivateReply";
+ /** @enum {string} */
+ readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
- readonly enabled: boolean;
- readonly replyText: string;
+ readonly version: number;
+ /** @description Empty text clears the local value for this asset. */
+ readonly privateWelcomeMessage?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPublicReply?: string;
+ /** @description Empty text clears the local value for this asset. */
+ readonly commentPrivateReply?: string;
};
readonly ReplyRequest: {
readonly inReplyToRef: string;
From 8123a8d2b9f141d83a36fc61cd4543c8242594f3 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 19:16:36 -0300
Subject: [PATCH 08/13] fix(m5-05): allow versioned config browser headers
---
apps/api/src/common/security/portal-request-security.ts | 5 +++--
apps/api/test/m3-04-session.spec.ts | 8 ++++++--
docs/delivery/M5/M5.05.md | 6 +++++-
tools/m5-05/run-focused-api-test.mjs | 3 ++-
4 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/apps/api/src/common/security/portal-request-security.ts b/apps/api/src/common/security/portal-request-security.ts
index 62966c78..1b66ce43 100644
--- a/apps/api/src/common/security/portal-request-security.ts
+++ b/apps/api/src/common/security/portal-request-security.ts
@@ -69,6 +69,7 @@ export function portalRequestSecurityMiddleware(
}
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
+ response.setHeader('Access-Control-Expose-Headers', 'ETag');
response.vary('Origin');
}
@@ -84,7 +85,7 @@ export function portalRequestSecurityMiddleware(
const requestedMethod = oneHeader(request.headers['access-control-request-method']);
const requestedHeaders = oneHeader(request.headers['access-control-request-headers']);
const allowedMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
- const allowedHeaders = new Set(['content-type', 'x-csrf-token', 'x-tenant-context', 'idempotency-key']);
+ const allowedHeaders = new Set(['content-type', 'x-csrf-token', 'x-tenant-context', 'idempotency-key', 'if-match']);
const normalizedHeaders = requestedHeaders?.split(',').map((header) => header.trim().toLowerCase()).filter(Boolean) ?? [];
if (origin === undefined || !requestedMethod || !allowedMethods.has(requestedMethod.toUpperCase()) ||
normalizedHeaders.some((header) => !allowedHeaders.has(header))) {
@@ -92,7 +93,7 @@ export function portalRequestSecurityMiddleware(
return;
}
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
- response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token, X-Tenant-Context, Idempotency-Key');
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token, X-Tenant-Context, Idempotency-Key, If-Match');
response.setHeader('Access-Control-Max-Age', '600');
response.status(HttpStatus.NO_CONTENT).end();
return;
diff --git a/apps/api/test/m3-04-session.spec.ts b/apps/api/test/m3-04-session.spec.ts
index 852b61ce..cc941430 100644
--- a/apps/api/test/m3-04-session.spec.ts
+++ b/apps/api/test/m3-04-session.spec.ts
@@ -398,6 +398,9 @@ test('mutations reject untrusted origins, Fetch Metadata and session CSRF mismat
const good = await invokeMiddleware(middleware, request({ ...baseHeaders, 'x-csrf-token': csrf }));
assert.equal(good.continued, true);
assert.equal(good.response.headerValue('access-control-allow-origin'), 'https://portal.example');
+ const readableResponse = await invokeMiddleware(middleware, request({ origin: 'https://portal.example' }, 'GET'));
+ assert.equal(readableResponse.continued, true);
+ assert.match(String(readableResponse.response.headerValue('access-control-expose-headers')), /(?:^|,\s*)ETag(?:,|$)/i);
const missingCsrf = await invokeMiddleware(middleware, request(baseHeaders, 'POST', '/api/v1/session/logout'));
assert.equal((missingCsrf.response.bodyValue() as { code: string }).code, 'CSRF_INVALID');
@@ -407,11 +410,12 @@ test('mutations reject untrusted origins, Fetch Metadata and session CSRF mismat
const preflight = await invokeMiddleware(middleware, request({
origin: 'https://portal.example',
- 'access-control-request-method': 'DELETE',
- 'access-control-request-headers': 'content-type,x-csrf-token',
+ 'access-control-request-method': 'PATCH',
+ 'access-control-request-headers': 'content-type,x-csrf-token,if-match',
}, 'OPTIONS'));
assert.equal(preflight.response.statusCodeValue(), 204);
assert.match(String(preflight.response.headerValue('access-control-allow-headers')), /X-CSRF-Token/);
+ assert.match(String(preflight.response.headerValue('access-control-allow-headers')), /If-Match/);
const legacyPreflight = await invokeMiddleware(middleware, request({
origin: 'https://portal.example',
'access-control-request-method': 'DELETE',
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index c597978d..e6005bae 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -10,6 +10,8 @@ Leituras exigem sessão verificada, contexto tenant atual, membership ativa e `R
`GET /api/v1/assets/{assetId}/automation-config` retorna ETag forte `"automation-vN"`. `PATCH` exige `If-Match` e versão do corpo correspondentes. O repositório bloqueia a linha do ativo e compara a versão antes de gravar; conflitos retornam `412` sem substituir o estado atual. Campos têm limite de 4096 caracteres e rejeitam controles inválidos. Erros de asset indisponível, precondição e conteúdo inválido retornam `404`, `412` e `422` com mensagens seguras.
+Para a configuração exata de Origin já permitida pelo servidor, CORS agora aceita `If-Match` no preflight e expõe `ETag` ao navegador. O allowlist de Origins, a validação same-origin/Fetch Metadata e o CSRF das mutações permanecem iguais; nenhuma origem foi acrescentada.
+
O valor atual, o histórico append-only de metadados, o evento `automation.config.changed` e o job de entrega local são gravados na mesma transação MySQL. O histórico registra campos alterados, hash do ator, versão e horário; não replica os textos. O evento contém somente referência interna do asset e versão. A configuração fica vinculada à chave interna `(tenant_id, asset_id)`, fora dos registros de credencial/autorização; o fluxo de reautorização não a limpa. O estado inicial é `UNKNOWN`, passa a `PENDING` ao salvar e não passa a `SYNCED` sem projeção confirmada.
## Critérios e evidência
@@ -37,7 +39,7 @@ O valor atual, o histórico append-only de metadados, o evento `automation.confi
A migração candidata reindexada usa schema version `23`, ID `202611120023-asset-automation-config`. Na base M5.04 integrada, M5.07 ocupa v21 e M5.03 ocupa v22; a migração M5.05 vem depois delas e seu checksum é recalculado a partir da definição. A evidência histórica v21 descrita abaixo pertence à base candidata anterior, antes desta recomposição. O teste MariaDB executa dentro de uma transação externa e faz rollback, incluindo as linhas de auditoria append-only e o job/outbox sintéticos. O schema dedicado `ca_meta_m5_05` permanece isolado e não foi apagado.
-O harness `tools/m5-05/run-focused-api-test.mjs` compila o `tsconfig.build.json` com TypeScript, aplica migrations apenas quando `M5_05_AUTOMATION_TEST=1`, compila os testes para diretório temporário exclusivo, executa os dois arquivos M5.05 e remove somente esse diretório. A integração lê `API_DATABASE_*` e a CLI usa `MIGRATION_DATABASE_*`; os valores de segredo não são impressos. Dependências seguem `package-lock.json` existente, sem dependência nova. O harness Angular escreve/parseia JUnit em pasta temporária e a remove.
+O harness `tools/m5-05/run-focused-api-test.mjs` compila o `tsconfig.build.json` com TypeScript, aplica migrations apenas quando `M5_05_AUTOMATION_TEST=1`, compila os testes para diretório temporário exclusivo, executa os dois arquivos M5.05 e a regressão do middleware M3.04 e remove somente esse diretório. A integração lê `API_DATABASE_*` e a CLI usa `MIGRATION_DATABASE_*`; os valores de segredo não são impressos. Dependências seguem `package-lock.json` existente, sem dependência nova. O harness Angular escreve/parseia JUnit em pasta temporária e a remove.
## Evidência do candidato
@@ -71,3 +73,5 @@ A branch `desktop/m5-05` foi rebaseada sobre o squash M5.04 `e73456d037d3e0d05be
O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado para substituir seu registro v21. A validação da migration v23 com MariaDB requer schema de teste isolado e fresco; esta recomposição não reutilizou o schema persistido.
No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida. A integração MariaDB com migration v23 permanece pendente de um schema de teste fresco.
+
+Na correção CORS posterior, Node 24.22.2 executou `npm run test:m5-05 --workspace=@ca-meta/api`: 16 passaram, nenhum falhou e a integração MariaDB foi ignorada localmente. O harness agora também executa a regressão M3.04 que verifica preflight PATCH com `If-Match` e exposição de `ETag` para Origin exata. A validação de migration v23 ocorre em um schema MariaDB novo separado do banco antigo.
diff --git a/tools/m5-05/run-focused-api-test.mjs b/tools/m5-05/run-focused-api-test.mjs
index ccd2fb11..870d7472 100644
--- a/tools/m5-05/run-focused-api-test.mjs
+++ b/tools/m5-05/run-focused-api-test.mjs
@@ -30,7 +30,7 @@ function run(args) {
try {
await mkdir(outputRoot, { recursive: true });
await mkdir(temporaryOutput, { recursive: false });
- process.stdout.write(`M5.05 focused API tests — Node ${process.version}\n`);
+ process.stdout.write(`M5.05 focused API + CORS regression tests — Node ${process.version}\n`);
if (!run([compiler, '--project', join(apiRoot, 'tsconfig.build.json'), '--pretty', 'false'])) {
process.exitCode = 1;
@@ -53,6 +53,7 @@ try {
'--test', '--test-concurrency=1',
join(temporaryOutput, 'test', 'm5-05-automation-config.spec.js'),
join(temporaryOutput, 'test', 'm5-05-automation-config.integration.spec.js'),
+ join(temporaryOutput, 'test', 'm3-04-session.spec.js'),
])) {
process.exitCode = 1;
}
From eb5a6550b0dc19242d993769be14f2cfc88ec8d5 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 19:21:51 -0300
Subject: [PATCH 09/13] docs(m5-05): record final review and v23 database
evidence
---
docs/delivery/M5/M5.05.md | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index e6005bae..0277d354 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -70,8 +70,10 @@ No SHA `7369c9a1174b829df58a538cd237c3168dffeaf2`, `tools/m5-05/run-focused-web-
A branch `desktop/m5-05` foi rebaseada sobre o squash M5.04 `e73456d037d3e0d05be2ab684820bb4be660390e`, reaplicando somente os seis commits M5.05 após a base antiga `ae867c6`. Os scripts focados M5.03/M5.04 foram preservados junto dos scripts M5.05. O módulo MetaAssets registra os controllers de Templates WhatsApp e Automation Config; o registry mantém migrations v21/v22 da base e registra M5.05 como v23. O cliente gerado M1.07 foi regenerado a partir do contrato espelhado combinado.
-O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado para substituir seu registro v21. A validação da migration v23 com MariaDB requer schema de teste isolado e fresco; esta recomposição não reutilizou o schema persistido.
+O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado para substituir seu registro v21. A migration v23 foi validada em outro schema MariaDB fresco e descartável, `ca_meta_m5_05_v23_36b9f62f`, no serviço Docker local `ca-meta-m5-db-20261111` (MariaDB `11.8.8-MariaDB-ubu2404`, `127.0.0.1:13307`), em worktree detached no SHA `c4773ba1016382071a5319cae957ead99f083cb0`. Com Node `v24.22.2`, npm `11.19.1` e `M5_05_AUTOMATION_TEST=1`, `npm.cmd run test:m5-05 --workspace=@ca-meta/api` aplicou migrations até v23 e passou 7/7, 0 falhas/0 skips, incluindo a transação MariaDB. A primeira execução parou no gate de privilégio de `DELETE` da v22; concedi somente `DELETE` table-scoped ao migrator de teste no schema descartável e repeti. Schema e usuários temporários foram removidos; verificação confirmou zero recursos temporários restantes. O banco v21 foi preservado.
-No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida. A integração MariaDB com migration v23 permanece pendente de um schema de teste fresco.
+No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida.
-Na correção CORS posterior, Node 24.22.2 executou `npm run test:m5-05 --workspace=@ca-meta/api`: 16 passaram, nenhum falhou e a integração MariaDB foi ignorada localmente. O harness agora também executa a regressão M3.04 que verifica preflight PATCH com `If-Match` e exposição de `ETag` para Origin exata. A validação de migration v23 ocorre em um schema MariaDB novo separado do banco antigo.
+O HEAD final do pacote é `8123a8d2b9f141d83a36fc61cd4543c8242594f3`. Após a correção CORS, Node 24.22.2 executou `npm run test:m5-05 --workspace=@ca-meta/api`: 16 passaram, 0 falhas e 1 skip intencional da integração MariaDB, que foi validada separadamente em schema fresco acima. O harness também executa a regressão M3.04 para preflight PATCH com `If-Match` e exposição de `ETag` após validação de Origin exata. Revisão funcional e security review passaram neste HEAD. Advisory de revisão: a migration não verifica FKs/CHECKs em schema preexistente; não foi confirmado bypass de tenant nem risco no caminho normal. A UI mantém o gate de identidade M1.08 e o executor de mensagens permanece desabilitado/fail-closed.
+
+A limpeza do worktree detached usado pela validação foi bloqueada pela política automática ao tentar remover recursivamente arquivos ignorados; o registro Git do worktree foi removido e os resíduos foram preservados em `C:\Users\user\.codex\worktrees\m5-05-validation-c4773\ca_meta_app`. O schema e usuários temporários já foram removidos.
From 3abc5d1800075f8acd0432bea9f43bf31ff1a1e0 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 19:39:50 -0300
Subject: [PATCH 10/13] fix(m5-05): preserve frozen automation config contract
---
.../automation-config.controller.ts | 8 +-
.../automation-config.service.ts | 6 +-
apps/api/test/m5-05-automation-config.spec.ts | 2 +-
.../portal/automation-config.service.spec.ts | 8 +-
.../core/portal/automation-config.service.ts | 8 +-
.../asset-automation-config.component.spec.ts | 2 +-
.../asset-automation-config.component.ts | 4 +-
docs/delivery/M5/M5.05.md | 2 +-
docs/delivery/M5/decisions.md | 8 +
docs/meta-v2/04-identity-api-and-security.md | 2 +-
docs/meta-v2/contracts/openapi.yaml | 164 ++++++++++++++++-
docs/meta-v2/experience/journey-spec.md | 2 +-
.../experience/screen-api-permission-test.csv | 2 +-
packages/contracts/openapi.yaml | 165 +++++++++++++++++-
packages/contracts/src/generated/openapi.d.ts | 137 ++++++++++++++-
tools/m1-07/src/generated/openapi.d.ts | 137 ++++++++++++++-
16 files changed, 626 insertions(+), 31 deletions(-)
diff --git a/apps/api/src/automation-config/automation-config.controller.ts b/apps/api/src/automation-config/automation-config.controller.ts
index 207a92f0..2620eda9 100644
--- a/apps/api/src/automation-config/automation-config.controller.ts
+++ b/apps/api/src/automation-config/automation-config.controller.ts
@@ -81,7 +81,7 @@ function mapAutomationError(error: unknown): never {
export class AutomationConfigController {
constructor(private readonly config: AutomationConfigService) {}
- @Get('assets/:assetId/automation-config')
+ @Get('assets/:assetId/automation-config/channels')
@PublicRoute()
@UseGuards(PortalSessionGuard)
async read(
@@ -89,7 +89,7 @@ export class AutomationConfigController {
@Req() request: PortalRequest,
@Headers('x-tenant-context') headerContext?: string,
@Res({ passthrough: true }) response?: Response,
- ): Promise {
+ ): Promise {
try {
const result = await this.config.read(request.portalSession, contextRef(request, headerContext), assetId, requestId(request));
response?.setHeader('ETag', automationConfigEtag(result.version));
@@ -97,7 +97,7 @@ export class AutomationConfigController {
} catch (error) { return mapAutomationError(error); }
}
- @Patch('assets/:assetId/automation-config')
+ @Patch('assets/:assetId/automation-config/channels')
@PublicRoute()
@UseGuards(PortalSessionGuard)
async update(
@@ -107,7 +107,7 @@ export class AutomationConfigController {
@Headers('x-tenant-context') headerContext?: string,
@Headers('if-match') ifMatch?: string,
@Res({ passthrough: true }) response?: Response,
- ): Promise {
+ ): Promise {
try {
const result = await this.config.update(request.portalSession, contextRef(request, headerContext), assetId,
requestId(request), parseIfMatch(ifMatch), body);
diff --git a/apps/api/src/automation-config/automation-config.service.ts b/apps/api/src/automation-config/automation-config.service.ts
index 95b882bf..5f161c03 100644
--- a/apps/api/src/automation-config/automation-config.service.ts
+++ b/apps/api/src/automation-config/automation-config.service.ts
@@ -48,7 +48,7 @@ export class AutomationConfigService {
private readonly environment: MetaEnvironment,
) {}
- async read(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string): Promise {
+ async read(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string): Promise {
const scope = await this.scope(session, contextRef);
await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId);
const asset = await this.findAsset(scope.tenantId, assetId);
@@ -59,7 +59,7 @@ export class AutomationConfigService {
}
async update(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string,
- ifMatchVersion: number, patch: AutomationConfigPatch): Promise {
+ ifMatchVersion: number, patch: AutomationConfigPatch): Promise {
const scope = await this.scope(session, contextRef);
await this.authorization.authorize(scope, 'MANAGE_AUTOMATION_CONFIG', scope.tenantId);
const asset = await this.findAsset(scope.tenantId, assetId);
@@ -92,7 +92,7 @@ export class AutomationConfigService {
}
private serialize(assetId: string, assetKind: components['schemas']['AutomationConfigAssetKind'],
- state: Awaited>, canEdit: boolean, requestId: string): components['schemas']['AutomationConfig'] {
+ state: Awaited>, canEdit: boolean, requestId: string): components['schemas']['AutomationChannelConfig'] {
return Object.freeze({
assetId,
assetKind,
diff --git a/apps/api/test/m5-05-automation-config.spec.ts b/apps/api/test/m5-05-automation-config.spec.ts
index 515c559d..f8181de6 100644
--- a/apps/api/test/m5-05-automation-config.spec.ts
+++ b/apps/api/test/m5-05-automation-config.spec.ts
@@ -176,7 +176,7 @@ test('channel capabilities keep WhatsApp number and Facebook/Instagram reply fie
});
test('controller returns strong version ETags and rejects malformed preconditions', async () => {
- const responseBody: components['schemas']['AutomationConfig'] = {
+ const responseBody: components['schemas']['AutomationChannelConfig'] = {
assetId: numberOne,
assetKind: 'WHATSAPP_PHONE_NUMBER',
inheritanceMode: 'ASSET_ONLY',
diff --git a/apps/web/src/app/core/portal/automation-config.service.spec.ts b/apps/web/src/app/core/portal/automation-config.service.spec.ts
index cdca773d..8c1038fb 100644
--- a/apps/web/src/app/core/portal/automation-config.service.spec.ts
+++ b/apps/web/src/app/core/portal/automation-config.service.spec.ts
@@ -5,7 +5,7 @@ import type { components } from '@ca-meta/contracts';
import { META_API_BASE_URL } from '../config/public-runtime-config';
import { AutomationConfigService } from './automation-config.service';
-type AutomationConfig = components['schemas']['AutomationConfig'];
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
describe('AutomationConfigService', () => {
let service: AutomationConfigService;
@@ -42,7 +42,7 @@ describe('AutomationConfigService', () => {
it('uses the internal asset endpoint and enforces response ETag versioning for reads and updates', async () => {
const loaded = service.read(config.assetId);
- const readRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ const readRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
expect(readRequest.request.method).toBe('GET');
readRequest.flush(config, { headers: { ETag: '"automation-v0"' } });
const current = await loaded;
@@ -50,7 +50,7 @@ describe('AutomationConfigService', () => {
const update = { version: 0, privateWelcomeMessage: 'Synthetic portal value' };
const saving = service.update(config.assetId, update, current.etag);
- const updateRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ const updateRequest = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
expect(updateRequest.request.method).toBe('PATCH');
expect(updateRequest.request.headers.get('If-Match')).toBe('"automation-v0"');
expect(updateRequest.request.body).toEqual(update);
@@ -62,7 +62,7 @@ describe('AutomationConfigService', () => {
it('rejects a response whose ETag does not match the body version', async () => {
const loaded = service.read(config.assetId);
- const request = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config`);
+ const request = http.expectOne(`/api/v1/assets/${config.assetId}/automation-config/channels`);
request.flush(config, { headers: { ETag: '"automation-v1"' } });
await expect(loaded).rejects.toThrow('The automation config response is incomplete.');
});
diff --git a/apps/web/src/app/core/portal/automation-config.service.ts b/apps/web/src/app/core/portal/automation-config.service.ts
index f531f10d..6885f90f 100644
--- a/apps/web/src/app/core/portal/automation-config.service.ts
+++ b/apps/web/src/app/core/portal/automation-config.service.ts
@@ -5,8 +5,8 @@ import type { components } from '@ca-meta/contracts';
import { META_API_BASE_URL } from '../config/public-runtime-config';
import { normalizeApiV1BaseUrl } from './portal-session.service';
-export type AutomationConfig = components['schemas']['AutomationConfig'];
-export type AutomationConfigUpdate = components['schemas']['AutomationConfigUpdate'];
+export type AutomationConfig = components['schemas']['AutomationChannelConfig'];
+export type AutomationConfigUpdate = components['schemas']['AutomationChannelConfigUpdate'];
export interface VersionedAutomationConfig {
readonly config: AutomationConfig;
@@ -27,14 +27,14 @@ export class AutomationConfigService {
async read(assetId: string): Promise {
const response = await firstValueFrom(this.http.get(
- `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config`, { observe: 'response' },
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config/channels`, { observe: 'response' },
));
return this.versioned(response);
}
async update(assetId: string, update: AutomationConfigUpdate, etag: string): Promise {
const response = await firstValueFrom(this.http.patch(
- `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config`, update,
+ `${this.baseUrl}/assets/${encodeURIComponent(assetId)}/automation-config/channels`, update,
{ observe: 'response', headers: { 'If-Match': etag } },
));
return this.versioned(response);
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
index 27f13330..90563a87 100644
--- a/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.spec.ts
@@ -7,7 +7,7 @@ import { PortalContextStore } from '../../core/portal/portal-context.store';
import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
import { AssetAutomationConfigComponent } from './asset-automation-config.component';
-type AutomationConfig = components['schemas']['AutomationConfig'];
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
describe('AssetAutomationConfigComponent', () => {
it('shows channel-supported fields and keeps edits disabled until the tenant identity gate is confirmed', async () => {
diff --git a/apps/web/src/app/features/portal/asset-automation-config.component.ts b/apps/web/src/app/features/portal/asset-automation-config.component.ts
index 3ebf650e..ba763424 100644
--- a/apps/web/src/app/features/portal/asset-automation-config.component.ts
+++ b/apps/web/src/app/features/portal/asset-automation-config.component.ts
@@ -10,9 +10,9 @@ import { PortalContextStore } from '../../core/portal/portal-context.store';
import { PortalTenantIdentityService } from '../../core/portal/portal-tenant-identity.service';
import { safeRequestId } from '../../core/portal/safe-api-error';
-type AutomationConfig = components['schemas']['AutomationConfig'];
+type AutomationConfig = components['schemas']['AutomationChannelConfig'];
type AutomationConfigField = components['schemas']['AutomationConfigField'];
-type AutomationConfigUpdate = components['schemas']['AutomationConfigUpdate'];
+type AutomationConfigUpdate = components['schemas']['AutomationChannelConfigUpdate'];
type EditableValues = {
privateWelcomeMessage: string;
commentPublicReply: string;
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index 0277d354..6719d09c 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -8,7 +8,7 @@ O catálogo central mantém `PrivateWelcomeMessage` por número do WhatsApp e `C
Leituras exigem sessão verificada, contexto tenant atual, membership ativa e `RESOURCE_READ`. Gravações exigem `MANAGE_AUTOMATION_CONFIG` e papel `owner`, `admin` ou `operator`; assets de outro tenant não são encontrados. O detalhe da conexão liga cada ativo suportado ao editor do próprio asset. A UI só oferece campos recebidos em `supportedFields` e mantém Salvar indisponível enquanto o gate de identidade M1.08 continuar sem empresa confirmada.
-`GET /api/v1/assets/{assetId}/automation-config` retorna ETag forte `"automation-vN"`. `PATCH` exige `If-Match` e versão do corpo correspondentes. O repositório bloqueia a linha do ativo e compara a versão antes de gravar; conflitos retornam `412` sem substituir o estado atual. Campos têm limite de 4096 caracteres e rejeitam controles inválidos. Erros de asset indisponível, precondição e conteúdo inválido retornam `404`, `412` e `422` com mensagens seguras.
+`GET /api/v1/assets/{assetId}/automation-config/channels` retorna ETag forte `"automation-vN"`. `PATCH` exige `If-Match` e versão do corpo correspondentes. O repositório bloqueia a linha do ativo e compara a versão antes de gravar; conflitos retornam `412` sem substituir o estado atual. Campos têm limite de 4096 caracteres e rejeitam controles inválidos. Erros de asset indisponível, precondição e conteúdo inválido retornam `404`, `412` e `422` com mensagens seguras. A rota `/channels` mantém a representação M5.05 aditiva e preserva o contrato v1 anterior em `/automation-config`.
Para a configuração exata de Origin já permitida pelo servidor, CORS agora aceita `If-Match` no preflight e expõe `ETag` ao navegador. O allowlist de Origins, a validação same-origin/Fetch Metadata e o CSRF das mutações permanecem iguais; nenhuma origem foi acrescentada.
diff --git a/docs/delivery/M5/decisions.md b/docs/delivery/M5/decisions.md
index 37e4614e..a1aca389 100644
--- a/docs/delivery/M5/decisions.md
+++ b/docs/delivery/M5/decisions.md
@@ -30,3 +30,11 @@
- **Decisão:** enquanto essa lacuna existir, o portal mostra “Identidade da empresa não confirmada”, oculta o seletor empresarial e falha fechado para toda mutação tenant-scoped. Leituras server-scoped e emissão pública de magic-link continuam disponíveis. Não derivar nome de `tenantRef`/`cUUID`, não inventar endpoint de rótulo e não liberar chamadas legadas externas quando um rótulo seguro for adicionado depois.
- **Consequência:** M5.01–M5.03 exercitam o fluxo de gravação apenas com capability confirmada sintética em testes. Em produção, ações permanecem indisponíveis até uma alteração revisada do contrato que forneça contexto de empresa confiável; identidade visível por si só nunca autoriza consumidores Msg4/PHP.
+## D-M5-06 — Configuração por canal em rota aditiva
+
+- **Critério da issue #59:** oferecer leitura e atualização PATCH da representação de mensagens/respostas por canal em `/api/v1/assets/{assetId}/automation-config`.
+- **Motivo:** essa rota e os schemas `AutomationConfig`/`AutomationConfigUpdate` já pertencem ao contrato v1 congelado e são usados por consumidores existentes. Substituí-los pela nova representação de M5.05 faz `check:breaking` falhar e remove os campos `enabled`/`replyText` legados.
+- **Decisão:** manter inalterados o GET/PUT e os schemas v1 na rota base. Publicar a representação M5.05 com `GET/PATCH /api/v1/assets/{assetId}/automation-config/channels` e schemas `AutomationChannelConfig`/`AutomationChannelConfigUpdate`.
+- **Consequência:** os consumidores M5.05 usam explicitamente a rota `/channels`; consumidores v1 continuam podendo usar seu contrato original. A issue diverge do caminho base pedido para manter compatibilidade binária de contrato.
+- **Gate:** aceitar a rota após revisão do diff de contrato e `check:breaking`; qualquer consolidação futura na rota base requer uma versão compatível, migração dos consumidores identificados e gate próprio de breaking-change.
+
diff --git a/docs/meta-v2/04-identity-api-and-security.md b/docs/meta-v2/04-identity-api-and-security.md
index ca8a16d9..3d7cd409 100644
--- a/docs/meta-v2/04-identity-api-and-security.md
+++ b/docs/meta-v2/04-identity-api-and-security.md
@@ -66,7 +66,7 @@ Os paths são contrato inicial para ratificação em OpenAPI. Segurança de toda
| Reautorizar | POST `/connections/:id/reauthorizations` | Cria onboarding novo, preserva grant anterior quando válido |
| Desconectar | DELETE `/connections/:id` ou comando tipado | Escopo e impacto descritos; cleanup externo separado |
| Templates | GET/POST `/whatsapp/accounts/:id/templates` | Conta/grant MANAGE_TEMPLATES, payload permitido |
-| Automação | PATCH `/assets/:id/automation-config` | Campos por canal, version/ETag e outbox |
+| Automação | GET/PATCH `/assets/:id/automation-config/channels` | Campos por canal, version/ETag e outbox; preserva GET/PUT e schemas v1 existentes na rota base |
| Notificações | GET `/notifications`, POST `/notifications/:id/actions` | Tenant completo e operationId; leitura separada de efeito externo |
| Operações | GET `/operations/:id` | Outcome incluindo UNKNOWN, sem token/resposta bruta |
| Webhook | GET/POST `/webhooks/meta/:appConfigRef` | Challenge/assinatura/raw body/limites; referência pública não é segredo |
diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml
index 1150539b..f9c45cb5 100644
--- a/docs/meta-v2/contracts/openapi.yaml
+++ b/docs/meta-v2/contracts/openapi.yaml
@@ -1777,6 +1777,122 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AutomationConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: asset_example_01
+ enabled: true
+ replyText: Thanks for contacting our team.
+ version: 7
+ updatedAt: 2026-10-29T11:00:00Z
+ requestId: req_example_30
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ put:
+ operationId: replaceAutomationConfig
+ tags:
+ - Configuration
+ summary: Replace automation/reply configuration conditionally
+ description: Requires exact Origin, CSRF, current tenant context and If-Match from the latest
+ representation. Unknown fields are rejected. This changes local configuration; delivery of a
+ reply is a separate external effect.
+ security: *a10
+ parameters:
+ - *a26
+ - *a27
+ - *a28
+ - *a29
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ description: Request uses a closed, size-bounded schema.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationConfigUpdate"
+ examples:
+ sanitized:
+ summary: Sanitized request example
+ value:
+ enabled: false
+ replyText: ""
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v8"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: asset_example_01
+ enabled: false
+ replyText: ""
+ version: 8
+ updatedAt: 2026-10-29T12:02:00Z
+ requestId: req_example_31
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ x-retry-policy: On 412, read the current representation and make an explicit new update.
+ /api/v1/assets/{assetId}/automation-config/channels:
+ parameters:
+ - name: assetId
+ in: path
+ required: true
+ description: Opaque resource identifier; authorization is always checked against the server-derived
+ tenant.
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
+ pattern: ^[A-Za-z0-9_-]+$
+ get:
+ operationId: getAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Read automation/reply configuration
+ description: Tenant and asset authorization are checked for the requested capability. ETag is
+ returned for conditional updates.
+ security: *a10
+ parameters: *a22
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v7"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
examples:
sanitized:
summary: Sanitized success example
@@ -1807,7 +1923,7 @@ paths:
"503": *a20
default: *a21
patch:
- operationId: replaceAutomationConfig
+ operationId: patchAutomationChannelConfig
tags:
- Configuration
summary: Update channel-supported automation/reply fields conditionally
@@ -1827,7 +1943,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AutomationConfigUpdate"
+ $ref: "#/components/schemas/AutomationChannelConfigUpdate"
examples:
whatsapp:
summary: Sanitized WhatsApp phone update
@@ -1845,7 +1961,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AutomationConfig"
+ $ref: "#/components/schemas/AutomationChannelConfig"
examples:
sanitized:
summary: Sanitized success example
@@ -4121,6 +4237,36 @@ components:
minLength: 1
maxLength: 128
AutomationConfig:
+ type: object
+ additionalProperties: false
+ required:
+ - assetId
+ - enabled
+ - replyText
+ - version
+ - updatedAt
+ - requestId
+ properties:
+ assetId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ enabled:
+ type: boolean
+ replyText:
+ type: string
+ maxLength: 4096
+ version:
+ type: integer
+ minimum: 1
+ updatedAt:
+ type: string
+ format: date-time
+ requestId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ AutomationChannelConfig:
type: object
additionalProperties: false
required:
@@ -4193,6 +4339,18 @@ components:
type: string
enum: [UNKNOWN, PENDING, SYNCED, STALE, FAILED]
AutomationConfigUpdate:
+ type: object
+ additionalProperties: false
+ required:
+ - enabled
+ - replyText
+ properties:
+ enabled:
+ type: boolean
+ replyText:
+ type: string
+ maxLength: 4096
+ AutomationChannelConfigUpdate:
type: object
additionalProperties: false
minProperties: 2
diff --git a/docs/meta-v2/experience/journey-spec.md b/docs/meta-v2/experience/journey-spec.md
index 4345d6ce..cf580faf 100644
--- a/docs/meta-v2/experience/journey-spec.md
+++ b/docs/meta-v2/experience/journey-spec.md
@@ -112,7 +112,7 @@ Actions are rendered from server state, user role, and capability:
## Existing features to preserve
- **Templates:** keep `/templates`, WhatsApp-number selection, list, status and creation. New contract path is `GET/POST /api/v1/whatsapp/accounts/{assetId}/templates`; it is WhatsApp-account scoped and requires `MANAGE_TEMPLATES`. Show `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`, `UNKNOWN` as distinct plain-language states. The current Angular form includes language/examples/body; the M1.07 `TemplateCreateRequest` only specifies name/language/category/bodyText. Until the contract preserves the existing language/example inputs, disable template creation with the explanation “Não é possível enviar este formulário sem preservar o idioma e os exemplos.” Never submit a reduced payload or imply that a disabled button is an error in the user's account.
-- **Automation/messages:** keep the private welcome and, where supported by current product behavior, public/private comment reply settings. New contract endpoint is `GET/PATCH /api/v1/assets/{assetId}/automation-config`, ETag-protected. The M1.07 `AutomationConfig` currently has only `enabled` and one `replyText`, not the baseline's private welcome/public comment/private comment fields. Keep the current editor read-only or save disabled with a clear compatibility explanation until a typed contract maps every existing field by channel. On a stale `412`, reload and let the user review before saving again. A saved local configuration is not proof the provider delivered a message.
+- **Automation/messages:** keep the private welcome and, where supported by current product behavior, public/private comment reply settings. The additive M5.05 endpoint is `GET/PATCH /api/v1/assets/{assetId}/automation-config/channels`, ETag-protected. The frozen v1 `GET/PUT /api/v1/assets/{assetId}/automation-config` route and its schemas remain unchanged. The frozen M1.07 `AutomationConfig` still has only `enabled` and `replyText`; the new typed `AutomationChannelConfig` maps the channel-specific fields for the M5.05 editor without replacing that v1 shape. Keep saves disabled while the M1.08 tenant-identity gate is unconfirmed. On a stale `412`, reload and let the user review before saving again. A saved local configuration is not proof the provider delivered a message.
- **Notifications:** retain bell, loading/empty/error/refresh, and supported actions. New `GET /api/v1/notifications` only returns `safeSummary` and references; the baseline notification card uses `MessageText`. Keep a concise safe summary and status, but do not silently substitute it for a message transcript. The full message/reply-context contract is a gate before migration of message display. Render `actions[]` only through approved labels; `MARK_READ`/`DISMISS` are local. `REPLY` is an external effect and returns a pending job; keep it disabled with an explanation until authorized message context and the reply path are available. When an action outcome is unknown, reconcile before offering another send.
- **No feature removal:** differences between the legacy UI and the proposed API are contract migration work. Do not remove template fields, notification actions, language, message behavior, or status detail without an explicit product decision and the affected tests.
diff --git a/docs/meta-v2/experience/screen-api-permission-test.csv b/docs/meta-v2/experience/screen-api-permission-test.csv
index 7271f31c..5bedda86 100644
--- a/docs/meta-v2/experience/screen-api-permission-test.csv
+++ b/docs/meta-v2/experience/screen-api-permission-test.csv
@@ -13,7 +13,7 @@ Refresh asset,STATE-09,/channels selected asset,refreshAsset POST /api/v1/assets
Reauthorize connection,STATE-06,/channels selected connection,createReauthorization POST /api/v1/connections/{connectionId}/reauthorizations,session current tenant + resource authorization + CSRF; exact role code Not confirmed,202 JobAccepted lacks onboarding ID/launch URL so keep action disabled with explanation until the contract exposes a safe reference,read returned job but do not offer provider launch without a session/launch ref,walkthrough REC-03 STATE-06,Prior verified grant remains while valid; API contract gap blocks full reauthorization UX
Disconnect connection,STATE-08,/channels detail confirmation,disconnectConnection DELETE /api/v1/connections/{connectionId},operator/owner scope + current tenant + CSRF,connection-specific scope and async job,202 means request started; status/job reconcile; UNKNOWN cannot be resubmitted automatically,walkthrough REC-03 STATE-08,Affected provider revocation/cleanup is separate and may be UNKNOWN
Templates list/create,FEATURE-01,/templates,listTemplates GET and createTemplate POST /api/v1/whatsapp/accounts/{assetId}/templates,session current tenant + asset grant with MANAGE_TEMPLATES for create,per-number list with status; existing name/category/language/body/example UI,creation disabled with explanation until contract preserves language/examples; never send reduced payload silently,walkthrough FEATURE-01,Current UI includes example values but M1.07 request does not
-Automation config,FEATURE-02,/channels automation editor,getAutomationConfig GET and replaceAutomationConfig PATCH /api/v1/assets/{assetId}/automation-config,session current tenant + requested asset capability; PATCH uses ETag/If-Match,baseline has three channel-specific fields while M1.07 only has enabled/replyText; editor is read-only and save is disabled until fully mapped,412 reload then explicit review; no reduced payload,walkthrough FEATURE-02,Exact capability names by channel Not confirmed; preserve all existing messages before enabling writes
+Automation config,FEATURE-02,/channels automation editor,getAutomationChannelConfig GET and patchAutomationChannelConfig PATCH /api/v1/assets/{assetId}/automation-config/channels; frozen-v1 getAutomationConfig GET and replaceAutomationConfig PUT remain at /api/v1/assets/{assetId}/automation-config,session current tenant + requested asset capability; M5.05 PATCH uses ETag/If-Match,the frozen M1.07 schema has enabled/replyText; M5.05 AutomationChannelConfig maps channel fields and preserves them in its additive route; editor save disabled until the M1.08 identity gate is confirmed,412 reload then explicit review; no reduced payload,walkthrough FEATURE-02,Exact capability names by channel Not confirmed; preserve all existing messages before enabling writes
Notifications read,FEATURE-03,/channels notification bell,listNotifications GET /api/v1/notifications,authenticated session + current tenant; response returns safe summaries/references,loading/empty/error and action list from server,keep error distinct from empty; retry read only,walkthrough FEATURE-03,Do not render message bodies/raw provider payload from synthetic events
Notification action,FEATURE-03,/channels notification bell,actOnNotification POST /api/v1/notifications/{notificationId}/actions,current membership + asset grant + role + CSRF,render MARK_READ/DISMISS when returned; REPLY remains disabled until authorized message context is available,202 does not prove send; getJob/getOperation and no blind repeat,walkthrough FEATURE-03 REC-03,"Current notification UI uses MessageText; proposed model only has safeSummary/reference, so don't silently remove message context"
Template creation progress,FEATURE-01,/templates selected number,getJob GET /api/v1/jobs/{jobId} after createTemplate 202,tenant/resource access to returned job,status pending/approved/rejected/paused/unknown from template projection,don't resubmit on unknown; refresh status only,walkthrough FEATURE-01 REC-03,Template status lacks reason text in M1.07 schema
diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml
index 0f80dc2f..27e8fb5e 100644
--- a/packages/contracts/openapi.yaml
+++ b/packages/contracts/openapi.yaml
@@ -1916,6 +1916,123 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AutomationConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: asset_example_01
+ enabled: true
+ replyText: Thanks for contacting our team.
+ version: 7
+ updatedAt: 2026-10-29T11:00:00Z
+ requestId: req_example_30
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ put:
+ operationId: replaceAutomationConfig
+ tags:
+ - Configuration
+ summary: Replace automation/reply configuration conditionally
+ description: Requires exact Origin, CSRF, current tenant context and If-Match
+ from the latest representation. Unknown fields are rejected. This
+ changes local configuration; delivery of a reply is a separate external
+ effect.
+ security: *a10
+ parameters:
+ - *a25
+ - *a26
+ - *a27
+ - *a28
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ description: Request uses a closed, size-bounded schema.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationConfigUpdate"
+ examples:
+ sanitized:
+ summary: Sanitized request example
+ value:
+ enabled: false
+ replyText: ""
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v8"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationConfig"
+ examples:
+ sanitized:
+ summary: Sanitized success example
+ value:
+ assetId: asset_example_01
+ enabled: false
+ replyText: ""
+ version: 8
+ updatedAt: 2026-10-29T12:02:00Z
+ requestId: req_example_31
+ "400": *a11
+ "401": *a12
+ "403": *a13
+ "404": *a14
+ "409": *a15
+ "412": *a16
+ "413": *a17
+ "422": *a18
+ "429": *a19
+ "503": *a20
+ default: *a21
+ x-retry-policy: On 412, read the current representation and make an explicit new update.
+ /api/v1/assets/{assetId}/automation-config/channels:
+ parameters:
+ - name: assetId
+ in: path
+ required: true
+ description: Opaque resource identifier; authorization is always checked against
+ the server-derived tenant.
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
+ pattern: ^[A-Za-z0-9_-]+$
+ get:
+ operationId: getAutomationChannelConfig
+ tags:
+ - Configuration
+ summary: Read automation/reply configuration
+ description: Tenant and asset authorization are checked for the requested
+ capability. ETag is returned for conditional updates.
+ security: *a10
+ parameters: *a22
+ responses:
+ "200":
+ description: Sanitized success example
+ headers:
+ ETag:
+ schema:
+ type: string
+ example: '"automation-v7"'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AutomationChannelConfig"
examples:
sanitized:
summary: Sanitized success example
@@ -1947,7 +2064,7 @@ paths:
"503": *a20
default: *a21
patch:
- operationId: replaceAutomationConfig
+ operationId: patchAutomationChannelConfig
tags:
- Configuration
summary: Update channel-supported automation/reply fields conditionally
@@ -1968,7 +2085,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AutomationConfigUpdate"
+ $ref: "#/components/schemas/AutomationChannelConfigUpdate"
examples:
whatsapp:
summary: Sanitized WhatsApp phone update
@@ -1986,7 +2103,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AutomationConfig"
+ $ref: "#/components/schemas/AutomationChannelConfig"
examples:
sanitized:
summary: Sanitized success example
@@ -4194,6 +4311,36 @@ components:
minLength: 1
maxLength: 128
AutomationConfig:
+ type: object
+ additionalProperties: false
+ required:
+ - assetId
+ - enabled
+ - replyText
+ - version
+ - updatedAt
+ - requestId
+ properties:
+ assetId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ enabled:
+ type: boolean
+ replyText:
+ type: string
+ maxLength: 4096
+ version:
+ type: integer
+ minimum: 1
+ updatedAt:
+ type: string
+ format: date-time
+ requestId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ AutomationChannelConfig:
type: object
additionalProperties: false
required:
@@ -4290,6 +4437,18 @@ components:
- STALE
- FAILED
AutomationConfigUpdate:
+ type: object
+ additionalProperties: false
+ required:
+ - enabled
+ - replyText
+ properties:
+ enabled:
+ type: boolean
+ replyText:
+ type: string
+ maxLength: 4096
+ AutomationChannelConfigUpdate:
type: object
additionalProperties: false
minProperties: 2
diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts
index 2d7297c7..e17c872d 100644
--- a/packages/contracts/src/generated/openapi.d.ts
+++ b/packages/contracts/src/generated/openapi.d.ts
@@ -569,6 +569,33 @@ export interface paths {
* @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
*/
readonly get: operations["getAutomationConfig"];
+ /**
+ * Replace automation/reply configuration conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly put: operations["replaceAutomationConfig"];
+ readonly post?: never;
+ readonly delete?: never;
+ readonly options?: never;
+ readonly head?: never;
+ readonly patch?: never;
+ readonly trace?: never;
+ };
+ readonly "/api/v1/assets/{assetId}/automation-config/channels": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /**
+ * Read automation/reply configuration
+ * @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
+ */
+ readonly get: operations["getAutomationChannelConfig"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
@@ -578,7 +605,7 @@ export interface paths {
* Update channel-supported automation/reply fields conditionally
* @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
*/
- readonly patch: operations["replaceAutomationConfig"];
+ readonly patch: operations["patchAutomationChannelConfig"];
readonly trace?: never;
};
readonly "/api/v1/assets/{assetId}/replies": {
@@ -1187,6 +1214,15 @@ export interface components {
readonly requestId: string;
};
readonly AutomationConfig: {
+ readonly assetId: string;
+ readonly enabled: boolean;
+ readonly replyText: string;
+ readonly version: number;
+ /** Format: date-time */
+ readonly updatedAt: string;
+ readonly requestId: string;
+ };
+ readonly AutomationChannelConfig: {
/** Format: uuid */
readonly assetId: string;
readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
@@ -1220,6 +1256,10 @@ export interface components {
/** @enum {string} */
readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
+ readonly enabled: boolean;
+ readonly replyText: string;
+ };
+ readonly AutomationChannelConfigUpdate: {
readonly version: number;
/** @description Empty text clears the local value for this asset. */
readonly privateWelcomeMessage?: string;
@@ -2771,6 +2811,101 @@ export interface operations {
readonly default: components["responses"]["UnexpectedError"];
};
};
+ readonly getAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
+ readonly patchAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /**
+ * @description Exact configured browser Origin; wildcard matching is forbidden.
+ * @example https://portal.example.invalid
+ */
+ readonly Origin: components["parameters"]["Origin"];
+ /** @description Must match the configured exact-origin topology; cross-site, missing or invalid values fail. */
+ readonly "Sec-Fetch-Site": components["parameters"]["FetchSite"];
+ /** @description Session-bound anti-CSRF value; not a DTO property and not sufficient without the session cookie. */
+ readonly "X-CSRF-Token": components["parameters"]["CsrfToken"];
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ /**
+ * @description ETag from the latest representation. Missing/stale precondition is rejected; no blind overwrite.
+ * @example "automation-v7"
+ */
+ readonly "If-Match": components["parameters"]["IfMatch"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /** @description Request uses a closed, size-bounded schema. */
+ readonly requestBody: {
+ readonly content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfigUpdate"];
+ };
+ };
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
readonly sendReply: {
readonly parameters: {
readonly query?: never;
diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts
index 2d7297c7..e17c872d 100644
--- a/tools/m1-07/src/generated/openapi.d.ts
+++ b/tools/m1-07/src/generated/openapi.d.ts
@@ -569,6 +569,33 @@ export interface paths {
* @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
*/
readonly get: operations["getAutomationConfig"];
+ /**
+ * Replace automation/reply configuration conditionally
+ * @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
+ */
+ readonly put: operations["replaceAutomationConfig"];
+ readonly post?: never;
+ readonly delete?: never;
+ readonly options?: never;
+ readonly head?: never;
+ readonly patch?: never;
+ readonly trace?: never;
+ };
+ readonly "/api/v1/assets/{assetId}/automation-config/channels": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /**
+ * Read automation/reply configuration
+ * @description Tenant and asset authorization are checked for the requested capability. ETag is returned for conditional updates.
+ */
+ readonly get: operations["getAutomationChannelConfig"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
@@ -578,7 +605,7 @@ export interface paths {
* Update channel-supported automation/reply fields conditionally
* @description Requires exact Origin, CSRF, current tenant context and If-Match from the latest representation. Unknown fields are rejected. This changes local configuration; delivery of a reply is a separate external effect.
*/
- readonly patch: operations["replaceAutomationConfig"];
+ readonly patch: operations["patchAutomationChannelConfig"];
readonly trace?: never;
};
readonly "/api/v1/assets/{assetId}/replies": {
@@ -1187,6 +1214,15 @@ export interface components {
readonly requestId: string;
};
readonly AutomationConfig: {
+ readonly assetId: string;
+ readonly enabled: boolean;
+ readonly replyText: string;
+ readonly version: number;
+ /** Format: date-time */
+ readonly updatedAt: string;
+ readonly requestId: string;
+ };
+ readonly AutomationChannelConfig: {
/** Format: uuid */
readonly assetId: string;
readonly assetKind: components["schemas"]["AutomationConfigAssetKind"];
@@ -1220,6 +1256,10 @@ export interface components {
/** @enum {string} */
readonly AutomationConfigProjectionState: "UNKNOWN" | "PENDING" | "SYNCED" | "STALE" | "FAILED";
readonly AutomationConfigUpdate: {
+ readonly enabled: boolean;
+ readonly replyText: string;
+ };
+ readonly AutomationChannelConfigUpdate: {
readonly version: number;
/** @description Empty text clears the local value for this asset. */
readonly privateWelcomeMessage?: string;
@@ -2771,6 +2811,101 @@ export interface operations {
readonly default: components["responses"]["UnexpectedError"];
};
};
+ readonly getAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
+ readonly patchAutomationChannelConfig: {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /**
+ * @description Exact configured browser Origin; wildcard matching is forbidden.
+ * @example https://portal.example.invalid
+ */
+ readonly Origin: components["parameters"]["Origin"];
+ /** @description Must match the configured exact-origin topology; cross-site, missing or invalid values fail. */
+ readonly "Sec-Fetch-Site": components["parameters"]["FetchSite"];
+ /** @description Session-bound anti-CSRF value; not a DTO property and not sufficient without the session cookie. */
+ readonly "X-CSRF-Token": components["parameters"]["CsrfToken"];
+ /** @description Opaque current context reference returned by the session. It is not authorization without the cookie and current server membership. */
+ readonly "X-Tenant-Context": components["parameters"]["TenantContext"];
+ /**
+ * @description ETag from the latest representation. Missing/stale precondition is rejected; no blind overwrite.
+ * @example "automation-v7"
+ */
+ readonly "If-Match": components["parameters"]["IfMatch"];
+ };
+ readonly path: {
+ /** @description Opaque resource identifier; authorization is always checked against the server-derived tenant. */
+ readonly assetId: string;
+ };
+ readonly cookie?: never;
+ };
+ /** @description Request uses a closed, size-bounded schema. */
+ readonly requestBody: {
+ readonly content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfigUpdate"];
+ };
+ };
+ readonly responses: {
+ /** @description Sanitized success example */
+ readonly 200: {
+ headers: {
+ readonly ETag?: string;
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": components["schemas"]["AutomationChannelConfig"];
+ };
+ };
+ readonly 400: components["responses"]["BadRequest"];
+ readonly 401: components["responses"]["Unauthorized"];
+ readonly 403: components["responses"]["Forbidden"];
+ readonly 404: components["responses"]["NotFound"];
+ readonly 409: components["responses"]["Conflict"];
+ readonly 412: components["responses"]["PreconditionFailed"];
+ readonly 413: components["responses"]["PayloadTooLarge"];
+ readonly 422: components["responses"]["Unprocessable"];
+ readonly 429: components["responses"]["TooManyRequests"];
+ readonly 503: components["responses"]["Unavailable"];
+ readonly default: components["responses"]["UnexpectedError"];
+ };
+ };
readonly sendReply: {
readonly parameters: {
readonly query?: never;
From a2ff4849e96c7ff18b4f8520f24c4aa2a10ff195 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 19:46:01 -0300
Subject: [PATCH 11/13] docs(m5-05): record compatibility checks on final
candidate
---
docs/delivery/M5/M5.05.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index 6719d09c..738e69b0 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -74,6 +74,6 @@ O banco `ca_meta_m5_05` da evidência anterior não foi apagado nem atualizado p
No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd871a`, mais as atualizações locais de documentação/cliente gerado e expectativa v23), Node 24.22.2/npm 11.19.1 validou: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente e 53 linhas de compatibilidade); `npm run typecheck --prefix tools/m1-07`; `npm run test:m5-05 --workspace=@ca-meta/api` (6 testes passaram; integração MariaDB foi ignorada com `M5_05_AUTOMATION_TEST=0` para preservar o schema retido); `migration-unit.spec.ts` (9/9, incluindo sequência e checksum v23); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). `git diff --check` passou. A suíte de migration-unit foi compilada em diretório privado temporário `apps/api/.test-runs/m5-05-migration-unit-*`, executada e removida.
-O HEAD final do pacote é `8123a8d2b9f141d83a36fc61cd4543c8242594f3`. Após a correção CORS, Node 24.22.2 executou `npm run test:m5-05 --workspace=@ca-meta/api`: 16 passaram, 0 falhas e 1 skip intencional da integração MariaDB, que foi validada separadamente em schema fresco acima. O harness também executa a regressão M3.04 para preflight PATCH com `If-Match` e exposição de `ETag` após validação de Origin exata. Revisão funcional e security review passaram neste HEAD. Advisory de revisão: a migration não verifica FKs/CHECKs em schema preexistente; não foi confirmado bypass de tenant nem risco no caminho normal. A UI mantém o gate de identidade M1.08 e o executor de mensagens permanece desabilitado/fail-closed.
+O HEAD final do pacote é `3abc5d1800075f8acd0432bea9f43bf31ff1a1e0`. Com Node 24.22.2/npm 11.19.1, os checks exatos deste SHA passaram: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente gerado e 53 linhas de compatibilidade); `npm run check:breaking --prefix tools/m1-07` (nenhuma quebra no contrato v1); `npm run test:m5-05 --workspace=@ca-meta/api` (16 passaram, 0 falhas, 1 skip intencional da integração MariaDB); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). O harness API cobre também preflight PATCH com `If-Match` e exposição de `ETag` após Origin exata. A integração MariaDB v23 passou separadamente 7/7 em schema fresco no SHA `c4773ba`; o código da migration/registry é idêntico neste SHA. Revisão funcional e security review passaram em `3abc5d1`; `git diff-tree --check HEAD^ HEAD` passou no commit do candidato. Advisory: migration não verifica FKs/CHECKs em schema preexistente; não foi confirmado bypass de tenant. A UI mantém o gate de identidade M1.08 e o executor de mensagens permanece desabilitado/fail-closed.
A limpeza do worktree detached usado pela validação foi bloqueada pela política automática ao tentar remover recursivamente arquivos ignorados; o registro Git do worktree foi removido e os resíduos foram preservados em `C:\Users\user\.codex\worktrees\m5-05-validation-c4773\ca_meta_app`. O schema e usuários temporários já foram removidos.
From b6554452485b12aa434607f75f190d1441272633 Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 20:03:26 -0300
Subject: [PATCH 12/13] test(m2-03): follow registry through M5.05 v23
---
apps/api/test/migrations.integration.spec.ts | 14 +++++++++-----
tools/m2-03/README.md | 2 +-
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/apps/api/test/migrations.integration.spec.ts b/apps/api/test/migrations.integration.spec.ts
index eeeb7bd3..504b5240 100644
--- a/apps/api/test/migrations.integration.spec.ts
+++ b/apps/api/test/migrations.integration.spec.ts
@@ -207,10 +207,14 @@ test('MariaDB migration ledger, implicit-commit recovery, backfill, role separat
'the fresh schema pauses safely at v22 when the table-only trigger-definer grant is absent',
);
const stagedStatus = await readMigrationStatus(environment);
- assert.equal(stagedStatus.migrations.length, MIGRATIONS.length);
- assert.ok(stagedStatus.migrations.slice(0, -1).every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
- assert.equal(stagedStatus.migrations.at(-1)?.id, ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id);
- assert.equal(stagedStatus.migrations.at(-1)?.state, 'RUNNING');
+ const coexistenceLockIndex = MIGRATIONS.findIndex(
+ (migration) => migration.id === ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id,
+ );
+ assert.ok(coexistenceLockIndex >= 0, 'the Coexistence lock migration is registered');
+ assert.equal(stagedStatus.migrations.length, coexistenceLockIndex + 1, 'the failed deployment stops at the Coexistence lock migration');
+ assert.ok(stagedStatus.migrations.slice(0, coexistenceLockIndex).every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
+ assert.equal(stagedStatus.migrations[coexistenceLockIndex]?.id, ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id);
+ assert.equal(stagedStatus.migrations[coexistenceLockIndex]?.state, 'RUNNING');
const stagedSteps = await queryRows<{ step_id: string; state: string }>(grantAdmin,
`SELECT step_id, state FROM ${MIGRATION_STEP_LEDGER} WHERE migration_id = ?`,
[ONBOARDING_COEXISTENCE_LOCK_MIGRATION.id],
@@ -235,7 +239,7 @@ test('MariaDB migration ledger, implicit-commit recovery, backfill, role separat
);
await deployMigrations(environment);
const recoveredStatus = await readMigrationStatus(environment);
- assert.equal(recoveredStatus.migrations.length, 22);
+ assert.equal(recoveredStatus.migrations.length, MIGRATIONS.length);
assert.ok(recoveredStatus.migrations.every((migration) => migration.state === 'APPLIED' && migration.checksumMatches));
const recoveredTrigger = await queryRows<{ trigger_name: string }>(grantAdmin,
'SELECT trigger_name FROM information_schema.triggers WHERE trigger_schema = ? AND trigger_name = ?',
diff --git a/tools/m2-03/README.md b/tools/m2-03/README.md
index dff5bd42..4a413f76 100644
--- a/tools/m2-03/README.md
+++ b/tools/m2-03/README.md
@@ -1,6 +1,6 @@
# M2.03 synthetic migration harness
-The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–22: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, and the M5.03 Coexistence onboarding lock v22. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions.
+The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–23: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, the M5.03 Coexistence onboarding lock v22, and the M5.05 per-channel asset automation configuration v23. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions.
Prerequisites: Node 24.22.2, npm 11.19.1, Docker Engine, Docker Compose v2, and network access to the pinned MariaDB image on its first run. The PowerShell harness verifies those exact versions. It uses `%LOCALAPPDATA%\Codex\runtimes\node-v24.22.2` by default; set `M2_NODE_RUNTIME_DIR` to that runtime directory when it is elsewhere.
From 0237676ba3b62f8bcfa40a79647804de7f1b56ca Mon Sep 17 00:00:00 2001
From: Eduardo Matuella
Date: Thu, 12 Nov 2026 20:04:01 -0300
Subject: [PATCH 13/13] docs(m5-05): record M2.03 gate on v23
---
docs/delivery/M5/M5.05.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/delivery/M5/M5.05.md b/docs/delivery/M5/M5.05.md
index 738e69b0..08743f04 100644
--- a/docs/delivery/M5/M5.05.md
+++ b/docs/delivery/M5/M5.05.md
@@ -77,3 +77,7 @@ No snapshot rebased sobre `e73456d` (HEAD `c114b0031bc354d35973d12cacd8caa07fbd8
O HEAD final do pacote é `3abc5d1800075f8acd0432bea9f43bf31ff1a1e0`. Com Node 24.22.2/npm 11.19.1, os checks exatos deste SHA passaram: `npm run check:generated --workspace=@ca-meta/contracts` (12 artefatos); `npm run check:generated --prefix tools/m1-07` (cliente gerado e 53 linhas de compatibilidade); `npm run check:breaking --prefix tools/m1-07` (nenhuma quebra no contrato v1); `npm run test:m5-05 --workspace=@ca-meta/api` (16 passaram, 0 falhas, 1 skip intencional da integração MariaDB); e `npm run test:m5-05 --workspace=@ca-meta/web` (10/10). O harness API cobre também preflight PATCH com `If-Match` e exposição de `ETag` após Origin exata. A integração MariaDB v23 passou separadamente 7/7 em schema fresco no SHA `c4773ba`; o código da migration/registry é idêntico neste SHA. Revisão funcional e security review passaram em `3abc5d1`; `git diff-tree --check HEAD^ HEAD` passou no commit do candidato. Advisory: migration não verifica FKs/CHECKs em schema preexistente; não foi confirmado bypass de tenant. A UI mantém o gate de identidade M1.08 e o executor de mensagens permanece desabilitado/fail-closed.
A limpeza do worktree detached usado pela validação foi bloqueada pela política automática ao tentar remover recursivamente arquivos ignorados; o registro Git do worktree foi removido e os resíduos foram preservados em `C:\Users\user\.codex\worktrees\m5-05-validation-c4773\ca_meta_app`. O schema e usuários temporários já foram removidos.
+
+## Harness M2.03 com a migration v23
+
+O primeiro gate `m2-08-mariadb` no head `a2ff484` falhou porque o fixture M2.03 tratava a migration v22 como última da registry e esperava 22 entradas depois da recuperação. A fixture agora localiza a migration Coexistence v22 pelo ID, verifica que o ledger termina nela durante a pausa e compara a recuperação com `MIGRATIONS.length`; `tools/m2-03/README.md` lista também a M5.05 v23. No código `b6554452485b12aa434607f75f190d1441272633`, o comando `M2_NODE_RUNTIME_DIR=C:\Users\user\AppData\Local\Temp\ca-meta-m5-node-runtime-20261111 pwsh -NoLogo -NoProfile -File tools/m2-03/run-mariadb-tests.ps1` passou com Node 24.22.2/npm 11.19.1 e MariaDB 11.8.8: 392 testes, 0 falhas, 20 skips; migrations 1–23 aplicadas; backup/restore sintético verificou 10.005 ativos e preservação do ledger. A fixture descartável foi removida pelo próprio harness.