Docs Index
guarded write ownership enforcement.md

Proof Card: guarded-write Ownership Enforcement (PR 13b + PR 13c-3 + PR 13d)

Status: production Last verified: 2026-06-11

Claim

guardedWrite() is the single chokepoint for every direct connector mutation in the application. It gates 29 callsites (12 HubSpot routes + 6 fixture/finance/syncErrorAssist callsites + 11 services migrated in Stage A2.5: IntegrationService, IntegrationExecutor, SyncCentralOrchestrator, FlowExecutor.dispatch, SyncErrorAssistService) plus the FlowExecutor unified write path. The helper:

  1. Calls OwnershipResolver.validateWrite and throws OwnershipViolationError (reject_with_alert), OwnershipBlockedError (source_wins + non-owner caller), or OwnershipFieldLevelMergeBlockedError (merge_field_level block) for non-owner writes. The queue_for_human policy is live as of PR 13c-2guardedWrite encrypts WriteDescriptor.args via the global EncryptionService (AES-256-GCM, same key + AAD as AI-provider API-key storage), persists the encrypted envelope into governance_approvals.write_descriptor, and throws OwnershipPendingApprovalError(queueId) so the route layer maps to 202 with pollUrl. OwnershipResumeHandler decrypts on operator approval before re-dispatching the original mutation.
  2. After ownership allow AND for SourceSystem callers only, calls OwnershipResolver.detectLoop and throws LoopDetectedError on a reciprocal-write hazard. Non-SourceSystem callers (operator_action, sync_error_remediation, webhook_relay, integration_engine, sync_orchestrator) skip loop detection — explicit by design via the isSourceSystem type guard.
  3. Permits operator override of reject_with_alert, source_wins, and merge_field_level policies when caller is operator_action and override.permitted === true. Loop detection gating is precise: LoopDetectedError is non-overridable WHEN it fires — but the detectLoop check itself only runs for SourceSystem callers (isSourceSystem gate), because lineage events are keyed by SourceSystem and operator_action is not in that set by construction. So override-initiated writes (always operator_action callers) skip the loop check entirely — there's no chain for detectLoop to find since operator_action isn't in any reciprocal-write lineage. Copilot R19 on PR #851 flagged the prior "never bypasses loop detection" phrasing as imprecise. queue_for_human is non-overridable; PR 13c-2 still routes through the enqueue path even when an override is present, because the policy decision is "policy-mandated human approval" rather than "policy-mandated block" — operator override is the mechanism for the OPERATOR to act on the queued write, not a bypass that skips the queue. Future enhancement: thread a synthetic lineage identity for operator writes if we want override-initiated writes to participate in loop detection.
  4. Applies merge_field_level payload filtering only when the callsite supplies fieldLevelPayload: owner/non-merge/override paths receive the original payload, while field-level merge paths receive the exact-leaf allowed subset or fail closed. Field names may be audited; field values are never logged in ownership metadata.
  5. Emits a decision audit row + outcome audit row on every path; override paths emit a third "override" row (decision → override → outcome).

A CI gate (scripts/check-guarded-writes.mjs) walks the TypeScript AST for every src/ file outside connectors/, migrations/, tests/, scripts/, and the two legitimate dispatcher exemptions, and fails any call to a mutating IConnector method that is not nested inside a guardedWrite() do-callback. The receiver is type-checked against the IConnector interface — string-name matches on .create/update/delete/bulk* against unrelated types are not false-positives.

The source-of-truth coverage gate also enforces the PR 13d field-level contract: any guardedWrite({...}) context with fieldPaths must supply sibling fieldLevelPayload, and any manifest entry declaring merge_field_level must define at least one fieldOverrides entry.

Queue-path durability is LIVE as of PR 13c-2: migration 050 added governance_approvals.write_descriptor (TEXT NULLABLE) in PR 13b. OwnershipResumeHandler.apply() is registered as the default for operationType='ownership_write' by the ApprovalResumeRegistry Inversify factory (the registration runs at registry construction time, not as a side-effect of resolving the handler binding — Copilot R1 cluster-A4). On operator approval the handler:

  1. Parses the persisted JSON and asserts the version: 1 discriminator (forward-compat for per-tenant envelope encryption later).
  2. Calls decryptDescriptor to recover the original WriteDescriptor.args via AES-256-GCM (fails closed on tamper / unknown version / shape mismatch).
  3. Re-runs OwnershipResolver.detectLoop for SourceSystem callers — approval may have arrived minutes/hours after enqueue and a reciprocal lineage chain may have formed in the interim. On loopDetected: true the handler throws LoopDetectedError and the worker records the approval as apply_failed.
  4. If the descriptor carries integrationConfigId, looks up the tenant-bound IntegrationConfig via ConfigurationService.getConfigurationForTenant(tenantId, configId) and calls ConnectorManager.initializeConnectorsForConfig(config) so the dispatched connector has the correct auth + base URL. Falls back to legacy getConnector(targetSystemId, targetSystemId) when no id is supplied (backward compat with descriptors persisted before this PR).
  5. Dispatches the original mutation (create/update/delete/bulk*) and emits a resume_from_queue audit row.

A CI gate (scripts/check-write-descriptor-equivalence.mjs) walks the TypeScript AST for every src/ file outside connectors/, migrations/, tests/, scripts/ and asserts that any guardedWrite({...}) site with both do and resume has matching (operation, entityType) between the closure body and the descriptor — preventing the class of bug where the closure creates a Contact but the descriptor describes a Customer update.

The operator surface ships at /api/governance/ownership-rejections, /api/governance/loop-detections, and /api/governance/approvals?reason=ownership&status=pending. All three are gated by validateGuestContext + requireApproverRole. A static demo dashboard renders at /governance-operations.html.

Source

Tests

Live vs Fixture

Known Gaps

Verification (60-second AI-reviewer recipe)

# 1. CI gate — 0 violations, 11 regression scenarios
node scripts/check-guarded-writes.mjs
bash tests/scripts/check-guarded-writes.test.sh

# 2. Helper + encryption + handler unit tests pass
npx jest --config=jest.fast.config.cjs \
  tests/unit/governance/sourceOfTruth/fieldLevelPayload.test.ts \
  tests/unit/governance/sourceOfTruth/guardedWrite.test.ts \
  tests/unit/governance/sourceOfTruth/OwnershipResolver.test.ts \
  tests/unit/services/governance/writeDescriptorEncryption.test.ts \
  tests/unit/services/governance/handlers/OwnershipResumeHandler.test.ts

# 3. queryGovernanceChecks + operationsRouter unit tests — 13 scenarios pass
npx jest --config=jest.fast.config.cjs \
  tests/unit/services/ai/orchestrator/AuditService.queryGovernanceChecks.test.ts \
  tests/unit/routes/governance/operationsRouter.test.ts

# 4. Integration tests — 10 scenarios pass (real services, mocked connector)
npx jest --config=jest.slow.config.cjs \
  tests/integration/guardedWrite.endToEnd.test.ts \
  tests/integration/FlowExecutor.guardedWriteUnification.test.ts

# 5. Migration 050 applied
sqlite3 .db "PRAGMA table_info(governance_approvals)" | grep write_descriptor

# 6. Coverage budget green
node scripts/check-core-coverage-budget.mjs

# 7. Source-of-truth policy gate green
npm run audit-source-of-truth-coverage

Expected: every command exits 0. Step 1 prints ✓ guarded-write coverage: 0 violations. Step 5 prints 8|write_descriptor|TEXT|0||0 (column index may vary). Step 6 prints Core coverage budget OK (60 files matched).