From a21d35a165f67b0745afa12baab0fde89dde508d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 21:23:44 -0400 Subject: [PATCH] Add immutable social audit records --- .../migrations/027-social-audit-records.ts | 62 +++++++++++++++++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/migration-service.ts | 4 ++ .../src/main/services/sdlc-service.ts | 2 +- .../src/main/services/snapshot-service.ts | 1 + .../src/main/services/social-audit-service.ts | 52 ++++++++++++++++ .../src/main/services/social-service.ts | 62 ++++++++++++++++++- AgentCompany-New/src/shared/domain.ts | 5 +- AgentCompany-New/src/shared/social-domain.ts | 15 +++++ .../tests/unit/domain-services.test.ts | 2 +- .../tests/unit/migration-service.test.ts | 4 ++ .../tests/unit/social-service.test.ts | 49 +++++++++++++++ 12 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/027-social-audit-records.ts create mode 100644 AgentCompany-New/src/main/services/social-audit-service.ts diff --git a/AgentCompany-New/src/main/database/migrations/027-social-audit-records.ts b/AgentCompany-New/src/main/database/migrations/027-social-audit-records.ts new file mode 100644 index 00000000..709a9839 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/027-social-audit-records.ts @@ -0,0 +1,62 @@ +export const socialAuditRecordsSchema = ` + create table if not exists social_audit_records ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + social_account_id text references social_accounts(id) on delete set null, + source_type text not null, + source_id text, + event_type text not null, + actor_type text not null, + actor_id text, + policy_decision text not null, + evidence_file_id text references file_objects(id) on delete set null, + metadata_json text not null, + created_at text not null, + unique(source_type, source_id, event_type) + ); + + insert into social_audit_records( + id, org_id, social_account_id, source_type, source_id, event_type, + actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at + ) + select + 'saud_' || lower(hex(randomblob(16))), + org_id, + social_account_id, + 'browser_action', + id, + action_type, + 'system', + null, + policy_decision, + evidence_file_id, + json_object('source', 'browser_actions'), + created_at + from browser_actions + where not exists ( + select 1 from social_audit_records + where social_audit_records.source_type = 'browser_action' + and social_audit_records.source_id = browser_actions.id + and social_audit_records.event_type = browser_actions.action_type + ); + + create index if not exists idx_social_audit_records_org_created + on social_audit_records(org_id, created_at); + create index if not exists idx_social_audit_records_account_created + on social_audit_records(social_account_id, created_at); + + create trigger if not exists trg_social_audit_records_no_update + before update on social_audit_records + begin + select raise(abort, 'Social audit records are immutable.'); + end; + + create trigger if not exists trg_social_audit_records_no_delete + before delete on social_audit_records + begin + select raise(abort, 'Social audit records are immutable.'); + end; + + insert into schema_migrations(version, name, applied_at) + values (27, 'social_audit_records', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index c7135086..a7851a62 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -25,6 +25,7 @@ import { providerRequestRuntimeSchema } from "./migrations/023-provider-request- import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request"; import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery"; import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance"; +import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records"; interface Migration { version: number; @@ -59,6 +60,7 @@ const MIGRATIONS: Migration[] = [ { version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema }, { version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema }, { version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema }, + { version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 256e66a7..5a6b9099 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -68,6 +68,7 @@ const LEGACY_TABLES = [ "browser_action_policies", "social_approval_routes", "browser_actions", + "social_audit_records", "scheduled_posts", "policy_rules", "compliance_exports", @@ -227,6 +228,9 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, { table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" }, + { table: "social_audit_records", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "social_audit_records", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "social_audit_records", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" }, { table: "scheduled_posts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "scheduled_posts", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, { table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" }, diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 124ac473..b72e526d 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 26, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 27, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 083137a6..81e1bec0 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -122,6 +122,7 @@ export class SnapshotService { .map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })), socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"), browserActions: list(this.db, "browser_actions", "created_at desc"), + socialAuditRecords: list(this.db, "social_audit_records", "created_at desc"), policyRules: list(this.db, "policy_rules", "created_at asc"), migrationReports: list(this.db, "migration_reports", "created_at desc"), rollbackSnapshots, diff --git a/AgentCompany-New/src/main/services/social-audit-service.ts b/AgentCompany-New/src/main/services/social-audit-service.ts new file mode 100644 index 00000000..9ecee00e --- /dev/null +++ b/AgentCompany-New/src/main/services/social-audit-service.ts @@ -0,0 +1,52 @@ +import type { DatabaseSync } from "node:sqlite"; +import { createId } from "../../shared/ids"; + +type SocialAuditMetadata = Record; + +export interface RecordSocialAuditInput { + orgId: string; + socialAccountId: string | null; + sourceType: string; + sourceId: string | null; + eventType: string; + actorType: string; + actorId: string | null; + policyDecision: string; + evidenceFileId?: string | null; + metadata: SocialAuditMetadata; + createdAt?: string; +} + +export class SocialAuditService { + constructor(private readonly db: DatabaseSync) {} + + record(input: RecordSocialAuditInput): string { + const id = createId("saud"); + this.db.prepare(` + insert into social_audit_records( + id, org_id, social_account_id, source_type, source_id, event_type, + actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.orgId, + input.socialAccountId, + requireText(input.sourceType, "Social audit source type is required."), + input.sourceId, + requireText(input.eventType, "Social audit event type is required."), + requireText(input.actorType, "Social audit actor type is required."), + input.actorId, + requireText(input.policyDecision, "Social audit policy decision is required."), + input.evidenceFileId ?? null, + JSON.stringify(input.metadata), + input.createdAt ?? new Date().toISOString(), + ); + return id; + } +} + +function requireText(value: string, message: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error(message); + return trimmed; +} diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index c018d946..f6e08bb8 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -10,6 +10,7 @@ import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLe import { createId } from "../../shared/ids"; import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; +import { SocialAuditService } from "./social-audit-service"; import { SOCIAL_BROWSER_ACTION, SocialBrowserPolicyService, @@ -75,17 +76,19 @@ interface GrantScope { export class SocialService { private readonly audit: AuditService; private readonly approvals: ApprovalService; + private readonly socialAudit: SocialAuditService; private readonly browserPolicy: SocialBrowserPolicyService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.approvals = new ApprovalService(db); + this.socialAudit = new SocialAuditService(db); this.browserPolicy = new SocialBrowserPolicyService(db); } createScheduledPost(input: CreateScheduledPostInput): string { this.requireAccount(input.orgId, input.socialAccountId); - this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT); + const policy = this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT); this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_ACTION.DRAFT); const body = this.requireText(input.bodyMd, "Post body is required."); const scheduledFor = this.requireSchedule(input.scheduledFor); @@ -117,6 +120,18 @@ export class SocialService { action: "drafted", payload: { socialAccountId: input.socialAccountId, scheduledFor, state: "draft" }, }); + this.socialAudit.record({ + orgId: input.orgId, + socialAccountId: input.socialAccountId, + sourceType: "scheduled_post", + sourceId: id, + eventType: "draft_created", + actorType: "employee", + actorId: input.createdByEmployeeId, + policyDecision: "allowed_by_policy", + metadata: { scheduledFor, actionType: policy.action_type, riskLevel: policy.risk_level }, + createdAt: now, + }); return id; } @@ -142,6 +157,18 @@ export class SocialService { action: `state_${input.state}`, payload: { state: input.state, approvalId }, }); + this.socialAudit.record({ + orgId: input.orgId, + socialAccountId: post.social_account_id, + sourceType: "scheduled_post", + sourceId: input.scheduledPostId, + eventType: `state_${input.state}`, + actorType: "employee", + actorId: input.employeeId, + policyDecision: policyDecisionForState(input.state, policy.approval_required === 1), + metadata: { state: input.state, approvalId, actionType: policy.action_type, riskLevel: policy.risk_level }, + createdAt: now, + }); } createAccount(input: CreateSocialAccountInput): string { @@ -198,6 +225,18 @@ export class SocialService { action: "created", payload: { platform: input.platform, handle }, }); + this.socialAudit.record({ + orgId: input.orgId, + socialAccountId: id, + sourceType: "social_account", + sourceId: id, + eventType: "account_created", + actorType: "employee", + actorId: input.createdByEmployeeId, + policyDecision: "allowed_employee_authorization", + metadata: { platform: input.platform, handle, authorizedEmployeeId: input.authorizedEmployeeId ?? null }, + createdAt: now, + }); return id; } @@ -225,10 +264,11 @@ export class SocialService { win.loadURL(PLATFORM_LOGIN_URL[account.platform]); const now = new Date().toISOString(); this.browserPolicy.markSessionUsed(container.id, now); + const browserActionId = createId("bact"); this.db.prepare(` insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at) values (?, ?, ?, ?, ?, ?, ?) - `).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now); + `).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now); this.audit.record({ orgId: account.org_id, entityType: "social_account", @@ -238,6 +278,18 @@ export class SocialService { action: "login_window_opened", payload: { platform: account.platform }, }); + this.socialAudit.record({ + orgId: account.org_id, + socialAccountId: account.id, + sourceType: "browser_action", + sourceId: browserActionId, + eventType: "login_window_opened", + actorType: "employee", + actorId: input.employeeId, + policyDecision: "allowed_human_login", + metadata: { platform: account.platform, containerId: container.id }, + createdAt: now, + }); } private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void { @@ -406,3 +458,9 @@ function isRecord(value: unknown): value is Record { function isRiskLevel(value: unknown): value is RiskLevel { return Object.values(RISK_LEVEL).includes(value as RiskLevel); } + +function policyDecisionForState(state: SetScheduledPostStateInput["state"], approvalRequired: boolean): string { + if (state === "scheduled" && approvalRequired) return "approval_requested"; + if (state === "published" && approvalRequired) return "allowed_after_approval"; + return "allowed_by_policy"; +} diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index fba2cbb0..1a3042c5 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -10,7 +10,7 @@ import type { ProviderRequestRecord } from "./provider-request-domain"; import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; import type { RollbackSnapshotRecord } from "./rollback-domain"; import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain"; -import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain"; +import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; export type { AuditReplayRunRecord } from "./audit-domain"; export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; @@ -24,7 +24,7 @@ export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } export type { RollbackSnapshotRecord } from "./rollback-domain"; export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; -export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain"; +export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const; export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const; @@ -449,6 +449,7 @@ export interface AppSnapshot { browserActionPolicies: BrowserActionPolicyRecord[]; socialApprovalRoutes: SocialApprovalRouteRecord[]; browserActions: BrowserActionRecord[]; + socialAuditRecords: SocialAuditRecord[]; policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; rollbackSnapshots: RollbackSnapshotRecord[]; diff --git a/AgentCompany-New/src/shared/social-domain.ts b/AgentCompany-New/src/shared/social-domain.ts index 04cb0cb0..acb9544a 100644 --- a/AgentCompany-New/src/shared/social-domain.ts +++ b/AgentCompany-New/src/shared/social-domain.ts @@ -66,3 +66,18 @@ export interface BrowserActionRecord { evidenceFileId: string | null; createdAt: string; } + +export interface SocialAuditRecord { + id: string; + orgId: string; + socialAccountId: string | null; + sourceType: string; + sourceId: string | null; + eventType: string; + actorType: string; + actorId: string | null; + policyDecision: string; + evidenceFileId: string | null; + metadataJson: string; + createdAt: string; +} diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 0e07b9d0..a17c0093 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index b2160fc4..4055c1f5 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -150,6 +150,7 @@ describe("migration service", () => { create table browser_action_policies(id text primary key, org_id text, social_account_id text); create table social_approval_routes(id text primary key, org_id text, social_account_id text, route_to_id text); create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text); + create table social_audit_records(id text primary key, org_id text, social_account_id text, evidence_file_id text); create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text); create table policy_rules(id text primary key, org_id text); create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text); @@ -218,6 +219,7 @@ describe("migration service", () => { insert into browser_action_policies(id, org_id, social_account_id) values ('browser-policy-1', 'org-1', 'social-1'); insert into social_approval_routes(id, org_id, social_account_id, route_to_id) values ('social-route-1', 'org-1', 'social-1', 'emp-1'); insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1'); + insert into social_audit_records(id, org_id, social_account_id, evidence_file_id) values ('social-audit-1', 'org-1', 'social-1', 'file-1'); insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1'); insert into policy_rules(id, org_id) values ('policy-1', 'org-1'); insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1'); @@ -256,6 +258,7 @@ describe("migration service", () => { expect(body.tableCounts.browser_session_containers).toBe(1); expect(body.tableCounts.browser_action_policies).toBe(1); expect(body.tableCounts.social_approval_routes).toBe(1); + expect(body.tableCounts.social_audit_records).toBe(1); expect(body.tableCounts.compliance_exports).toBe(1); expect(body.tableCounts.audit_replay_runs).toBe(1); expect(body.tableCounts.rollback_snapshots).toBe(1); @@ -278,6 +281,7 @@ describe("migration service", () => { expect(body.idMaps.browser_session_containers).toEqual(["container-1"]); expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]); expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]); + expect(body.idMaps.social_audit_records).toEqual(["social-audit-1"]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/social-service.test.ts b/AgentCompany-New/tests/unit/social-service.test.ts index 6371cf95..71a07f3b 100644 --- a/AgentCompany-New/tests/unit/social-service.test.ts +++ b/AgentCompany-New/tests/unit/social-service.test.ts @@ -81,6 +81,14 @@ describe("social service", () => { approvalKind: "social_publish", routeToId: employeeId, }); + expect(state.socialAuditRecords[0]).toMatchObject({ + socialAccountId: state.socialAccounts[0]?.id, + sourceType: "social_account", + eventType: "account_created", + actorType: "employee", + actorId: employeeId, + policyDecision: "allowed_employee_authorization", + }); expect(state.auditEvents.find((event) => event.entityType === "social_account")?.actorId).toBe(employeeId); expect(grantScope.actions).toEqual(["login", "draft", "approve"]); expect(grantScope.riskCeiling).toBe("medium"); @@ -153,6 +161,47 @@ describe("social service", () => { expect(state.scheduledPosts[0]?.state).toBe("published"); expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId); expect(state.scheduledPosts[0]?.approvalId).toBe(state.approvals[0]?.id); + expect(state.socialAuditRecords.map((record) => record.eventType).sort()).toEqual([ + "account_created", + "draft_created", + "state_published", + "state_scheduled", + ]); + expect(state.socialAuditRecords.find((record) => record.eventType === "state_scheduled")).toMatchObject({ + sourceId: postId, + policyDecision: "approval_requested", + }); + expect(state.socialAuditRecords.find((record) => record.eventType === "state_published")).toMatchObject({ + sourceId: postId, + policyDecision: "allowed_after_approval", + }); + connection.close(); + }); + + test("keeps social audit records immutable", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const social = new SocialService(connection.db); + let auditId = ""; + + connection.transaction(() => { + const orgId = createOrganization(organization); + const employeeId = firstEmployeeId(connection, orgId); + social.createAccount({ + orgId, + platform: SOCIAL_PLATFORM.X, + handle: "@acme", + displayName: "Acme", + createdByEmployeeId: employeeId, + authorizedEmployeeId: employeeId, + }); + auditId = (connection.db.prepare("select id from social_audit_records limit 1").get() as { id: string }).id; + }); + + expect(() => connection.db.prepare("update social_audit_records set policy_decision = ? where id = ?") + .run("changed", auditId)).toThrow("Social audit records are immutable."); + expect(() => connection.db.prepare("delete from social_audit_records where id = ?") + .run(auditId)).toThrow("Social audit records are immutable."); connection.close(); });