---
id: TST-CRM-ENT-793
title: Managed record assets integration
kind: integration
product_area: crm
module: entities
revision: 1
status: draft
source_sha:
  services-crm: a8d1364f1dd44b723bb5f9acba12f983e9ec8d69
repository: services-crm
files:
  - modules/entities/tests/integration/db/managed-assets.table.integration.spec.js
  - modules/entities/tests/integration/services/assets/asset-cleanup-handler.integration.spec.js
  - modules/entities/tests/integration/services/assets/clean-up-entity-assets.integration.spec.js
  - modules/entities/tests/integration/services/assets/entity-asset-reads.integration.spec.js
  - modules/entities/tests/integration/services/assets/record-assets-adoption.integration.spec.js
  - modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js
  - modules/entities/tests/integration/services/persistence/managed-assets.integration.spec.js
  - modules/entities/tests/integration/services/persistence/managed-assets.queries.integration.spec.js
implements:
  - VT-IMC-CRM-ENT-003-01
  - VT-IMC-CRM-ENT-010-01
  - VT-IMC-CRM-ENT-010-02
  - VT-IMC-CRM-ENT-010-03
  - ATS-CRM-ENT-014-06
environment: local
implementation_state: implemented
latest_result: not-run
latest_run_at: null
latest_run_ref: null
conformance: not-verified
estimated_minutes: null
estimate_basis: No measured duration is available for this cohesive group; per-file allowances are not runtime estimates.
isolation: >-
  The group creates its own objects and managed rows, and makes only its own rows due for cleanup. This
  matters because the sweep claims due work across workspaces. Cleanup restores the pipeline dependencies and
  removes owned rows; running serially avoids process-wide storage injection conflicts.
binding_scope: >-
  Bindings preserve the existing assertion contributions and exact source file. Composite scenarios still
  require every bound contribution and any unbound checks; consolidation does not certify conformance.
bindings:
  - scenario: ATS-CRM-ENT-014-06
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js#detaches
      the photo in the delete's own transaction
  - scenario: ATS-CRM-ENT-014-06
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js#moves
      the photo into the deleted folder
  - scenario: ATS-CRM-ENT-014-06
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js#leaves
      the photo attached, the delete never having committed
evidence:
  - 'services-crm:modules/entities/tests/integration/db/managed-assets.table.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/assets/asset-cleanup-handler.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/assets/clean-up-entity-assets.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/assets/entity-asset-reads.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/assets/record-assets-adoption.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js'
  - 'services-crm:modules/entities/tests/integration/services/persistence/managed-assets.integration.spec.js'
  - >-
    services-crm:modules/entities/tests/integration/services/persistence/managed-assets.queries.integration.spec.js
source_observations:
  - provenance: test
    source: 'services-crm:modules/entities/tests/integration/db/managed-assets.table.integration.spec.js'
    declared_test_titles:
      - managed_assets.table (integration)
      - the constraints the migration names
      - accepts a well-formed row
      - refuses a second row for the same object by managed_assets_object_uidx
      - refuses a second row with the same id in the tenant by managed_assets_pk
      - refuses a state the lifecycle does not have by managed_assets_state_check
      - 'accepts `failed`, the state the consumer leaves a file it gave up on, with the reason it recorded'
      - refuses a source the pipeline cannot have written by managed_assets_source_kind_check
      - refuses an object outside the row's own tenant folder by managed_assets_tenant_folder_check
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies.
  - provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/asset-cleanup-handler.integration.spec.js
    declared_test_titles:
      - the asset cleanup entry points (integration)
      - >-
        retires the deleted contact’s photo, the consumer’s entry point having configured where a tenant’s
        time zone is read
      - 'asks SQS to deliver no message again, every file of the batch having been settled'
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies. File storage uses an in-memory double; storage failures are injected by that double.
      The suite also supplies the collaborator replacements declared in its source; those replacements delimit
      its external-service claims.
  - provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/clean-up-entity-assets.integration.spec.js
    declared_test_titles:
      - cleanUpEntityAssets (integration)
      - claims exactly the rows that were due and publishes one message for each
      - 'names in each message the asset and nothing else, so no object key reaches the queue'
      - 'acknowledges every message of the batch, the consumer having settled each file'
      - 'reports what it did with each file: the unreferenced one discarded, the referenced one kept'
      - deletes the file nothing references
      - 'marks the row of the deleted file discarded, with nothing left to clean'
      - keeps the file a contact still references
      - 'repairs the row of that file to attached, the record having proved the save did commit'
      - 'leaves the row that is not yet due abandoned, for a later sweep'
      - keeps the file of the row that is not yet due
      - removes nothing but the one file it had claimed and verified
      - never touches a legacy object this pipeline did not record
      - leaves the contact holding the file the cleanup kept for it
      - a consumer whose deletion of the file fails
      - 'reports the message for SQS to deliver again, which is the whole of the retry'
      - 'leaves the row `discarding`, the claim still the consumer’s to finish on the next delivery'
      - keeps the file whose deletion failed
      - 'writes no retry schedule into Postgres, the row having no deadline of its own any more'
      - 'records no reason, the consumer having given nothing up yet'
      - a consumer whose deletion fails on the queue’s last receive
      - 'still reports the message, so SQS moves it to the dead-letter queue rather than dropping the work'
      - 'leaves the row `failed`, with the reason the consumer gave up for'
      - 'takes the row out of reach of every later sweep, a person deciding what becomes of the file'
      - 'keeps the file, nothing having deleted it'
      - a sweep whose message the queue refuses
      - counts the refused message as returned rather than published
      - 'puts the claim back, so no row is left in `discarding` by a sweep that published nothing'
      - keeps the file whose message never reached the queue
      - 'makes the row due at once, so the very next sweep offers it again and nothing waits on a delay'
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies. File storage uses an in-memory double; storage failures are injected by that double.
      The suite also supplies the collaborator replacements declared in its source; those replacements delimit
      its external-service claims.
  - provenance: test
    source: 'services-crm:modules/entities/tests/integration/services/assets/entity-asset-reads.integration.spec.js'
    declared_test_titles:
      - the entities’ asset reads (integration)
      - whether an organization still references a file
      - answers true for a logo an organization still holds
      - answers true for a file an organization holds in an image custom field
      - answers false for a logo no organization of the tenant holds
      - answers false in another tenant for a logo the first tenant holds
      - whether a contact still references a file
      - answers true for a photo a contact still holds
      - answers false for a photo no contact of the tenant holds
      - answers false in another tenant for a photo the first tenant holds
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies. The suite also supplies the collaborator replacements declared in its source; those
      replacements delimit its external-service claims.
  - provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/assets/record-assets-adoption.integration.spec.js
    declared_test_titles:
      - /
      - saving a record’s files through the shared record-asset module (integration)
      - a contact created with a photo and two file custom fields
      - 'stores a permanent reference in the photo, never the upload URL it was sent'
      - 'stores a permanent reference in each custom field value, never the upload URL it was sent'
      - answers the custom fields on the record with the same permanent references the values hold
      - 'gives the contact a UUIDv7 id, the id the binding allocates for a create that names none'
      - 'files all three under the contact’s own id, allocated before the first copy'
      - 'files none of them under a `new-` operation folder, as a create with no id once did'
      - copies each of the three sources exactly once
      - 'attaches one lifecycle row per field to the contact, each naming its own path'
      - 'writes every row of the save under one operation, so one failure would abandon them together'
      - 'an organization created with a logo, through the same binding'
      - files the logo under the organization’s own folder and id
      - attaches the logo’s lifecycle row to the organization
      - a save whose photo is a permanent file the tenant already stores
      - 'stores the reference as it was sent, the file already being permanent'
      - copies nothing at all
      - writes no second lifecycle row for a file the first contact’s row already covers
      - a create whose only copy fails
      - refuses the create
      - writes no contact row
      - writes no owned element of the contact either
      - writes no custom field value
      - 'consumes no contact number, the copy having failed before the transaction'
      - 'leaves the preparation abandoned, for the cleanup to take away'
      - 'leaves the upload in the staging area, so the client may send it again'
      - an update whose copy fails
      - refuses the update
      - leaves the stored photo exactly as it was
      - 'leaves the contact at the version it was read at, nothing having been written'
      - 'leaves the photo’s own lifecycle row attached, the save that failed never having touched it'
      - keeps the file the stored reference names where it is
      - a create where one of two copies fails
      - 'refuses the create, naming the failure of the one copy that could not be made'
      - 'writes no contact row, though the photo did copy'
      - writes no custom field value
      - 'leaves every row of the save abandoned, the copy that succeeded included'
      - a commit that fails after every copy succeeded
      - refuses the create for the id the tenant already uses
      - had already copied the file before the transaction refused it
      - leaves the contact that does exist exactly as it was
      - abandons the copy the failed commit made
      - 'leaves the upload in staging, so the same request may be sent again'
      - 'and then the cleanup, once the abandoned copy is due'
      - discards the copy no contact references
      - deletes that copy’s object from the bucket
      - 'leaves the file the contact does reference where it is, the check having found it'
      - the retry of an update whose commit failed
      - refuses the first attempt at the version it was given
      - 'copies the upload once across both attempts, the retry reusing what the first prepared'
      - stores the one copy as the contact’s photo
      - attaches that row to the contact
      - detaches the photo the contact no longer shows
      - a tenant reaching for another tenant’s files
      - 'refuses the staging upload of the first tenant, naming the field and the reason'
      - writes no contact for that refused create
      - records no lifecycle row in the other tenant for the upload it could not take
      - refuses the permanent file of the first tenant too
      - writes no contact for that refused create either
      - leaves the first tenant’s photo attached to its own contact throughout
      - what an update does to each custom file field
      - replaces the badge with the new file’s permanent reference
      - keeps the waiver the update did not name
      - detaches the badge the contact no longer shows
      - 'leaves the waiver’s row attached, no save having touched that field'
      - clears the badge when the next update sends null
      - removes the cleared badge’s value row and keeps the waiver’s
      - detaches the second badge the clearing removed
      - 'the order of a save: every copy before the record’s transaction'
      - /
      - records both copies and the transactions the save opened (the premise of the order below)
      - opens the record’s transaction only after every copy has finished
      - writes nothing of the record in the transactions the preparation opened
      - 'saves the contact all the same, with both permanent references'
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies. The suite also supplies the collaborator replacements declared in its source; those
      replacements delimit its external-service claims.
  - provenance: test
    source: 'services-crm:modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js'
    declared_test_titles:
      - retiring the files a record stops referencing (integration)
      - a photo replaced by another
      - 'detaches the old photo in the save that replaced it, due at once'
      - 'moves the old photo into the deleted folder, where the 30-day rule expires it'
      - 'takes the old photo off its permanent key, so its public link stops working'
      - 'records the old photo retired, with where it went'
      - leaves the new photo attached and where it is
      - a contact deleted with its photo
      - detaches the photo in the delete's own transaction
      - moves the photo into the deleted folder
      - takes the photo off its permanent key
      - records the photo retired
      - a deleted contact's photo that another contact reuses
      - 'keeps the file where it is, the other contact still showing it'
      - repairs the row to attached instead of retiring it
      - a delete that is refused
      - refuses the delete of a locked contact
      - 'leaves the photo attached, the delete never having committed'
      - an organization deleted with its logo
      - moves the logo into the deleted folder
      - records the logo retired
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies. File storage uses an in-memory double; storage failures are injected by that double.
      The suite also supplies the collaborator replacements declared in its source; those replacements delimit
      its external-service claims.
  - provenance: test
    source: 'services-crm:modules/entities/tests/integration/services/persistence/managed-assets.integration.spec.js'
    declared_test_titles:
      - managed_assets.table (integration)
      - recording the intent to write an object
      - 'writes the row the pipeline will attach, naming the object, the record and the field'
      - 'writes it in the state `preparing`, before the object exists'
      - leaves what storage will answer empty until the object is verified
      - stamps the caller that is saving the file
      - protects the row from cleanup until the preparation grace period has passed
      - 'records no cleanup failure, nothing having tried to clean the file up yet'
      - >-
        refuses to run inside a transaction, so a crash between the copy and the commit always leaves this
        trace
      - marking a prepared object ready
      - 'moves the row to `ready`, the object now verified in storage'
      - 'records the version, the media type and the size storage answered'
      - 'leaves an attached row as it is, a late marking never undoing a commit'
      - finding an object an earlier attempt prepared
      - 'answers the row of the same record, field and source, so a retry reuses the object'
      - 'answers nothing for another source, so a different upload is copied on its own'
      - answers nothing for another field of the same record
      - 'answers nothing for the create that names no record, whose files are its own'
      - answers a row young enough for the age the caller allows
      - 'answers nothing older than that age, so a stale import is fetched again'
      - answers nothing once cleanup has discarded the object
      - finding the row of an object by its key
      - 'answers the row of the bucket and key, so a reused reference is recognised as this pipeline’s'
      - 'answers nothing for a key this pipeline never wrote, which a legacy file is'
      - attaching prepared objects to the record
      - 'moves the row to `attached`, naming the record that now references the file'
      - 'leaves the row nothing to clean up, the record referencing the file'
      - 'answers how many rows it moved, so the save can refuse when cleanup took one'
      - 'attaches an abandoned row, which a retry of the same save may reuse'
      - 'attaches nothing cleanup has claimed, so no record ever references a deleted file'
      - attaches nothing to a record other than the one the row already names
      - 'refuses to run outside the record''s transaction, where it could not commit with the row'
      - detaching the object a record no longer references
      - moves the replaced file’s row to `detached`
      - >-
        makes the replaced file due for retirement at once (Andrew, 2026-10-08: replaced files go to the
        30-day deleted folder)
      - leaves the row of a file the record still references attached
      - refuses to run outside the record's transaction
      - abandoning the rows of a save that failed
      - moves the row that was still preparing to `abandoned`
      - moves the row that was ready to `abandoned`
      - 'leaves an attached row of the same operation alone, a commit that did happen standing'
      - 'answers how many rows it moved, the attached one not among them'
      - makes the abandoned rows due once their short grace period has passed
      - claiming the rows that are due
      - claims the row that is due
      - 'moves the claimed row to `discarding`, so no save can attach it any more'
      - 'chooses deletion for the row it claimed, the file never having been committed'
      - 'claims nothing that is not due yet, an in-flight save never losing its file'
      - 'leaves the row that is not due abandoned, for a later pass'
      - 'never claims a row it has already claimed, the queue holding the handover and no sweep republishing it'
      - >-
        claims no more than the limit it is given, the locked selection binding it rather than being re-read
        per row
      - never claims the same row twice when two sweeps run together
      - settling a claim
      - 'marks the row whose object was deleted `discarded`, with nothing left to clean'
      - repairs to `attached` the row whose file a record turned out to reference
      - 'puts a row the queue refused back to `abandoned`, where it came from'
      - 'makes the returned row due at once, so the very next sweep offers it again'
      - 'puts a refused retirement back to `detached`, so the next sweep retires it rather than deleting it'
      - 'leaves the row the consumer gave up on `failed`, with the reason it gave'
      - 'takes the failed row out of reach of every later sweep, nothing in Postgres retrying it'
      - 'discards nothing that is not claimed, a consumer never settling a row no sweep handed it'
      - repairs nothing that is not claimed
      - puts nothing that is not claimed back to due
      - 'fails nothing that is not claimed, so a stray message cannot write off an attached file'
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies.
  - provenance: test
    source: >-
      services-crm:modules/entities/tests/integration/services/persistence/managed-assets.queries.integration.spec.js
    declared_test_titles:
      - managed asset reads (integration)
      - findReusable
      - 'answers the whole row of the object an earlier attempt prepared, every column of the table mapped'
      - 'answers nothing for another object that names the same record, field and source'
      - answers nothing for another record of the same object
      - answers nothing for another field of the same record
      - 'answers nothing for another source, so a different upload is copied on its own'
      - answers nothing in another tenant for a row of the first tenant
      - 'answers the most recently created of several candidates, whatever order they were written in'
      - 'answers the row of a create, which names no record, only when the caller asks for no record'
      - 'answers a row a record already references, which a save may reference again'
      - 'answers nothing once cleanup has claimed the object, so no save reuses a file that is going away'
      - answers a row young enough for the age the caller allows
      - 'answers nothing older than that age, so a stale import is fetched again'
      - findByKey
      - 'answers the whole row of the bucket and key, so a reused reference is recognised as this pipeline’s'
      - 'answers nothing for a key this pipeline never wrote, which a legacy file is'
      - answers nothing for the same key in another bucket
      - answers nothing in another tenant for a key of the first tenant
      - claimDue
      - 'claims the rows that are due, in any order'
      - 'answers the whole row of each claim, in the state and with the disposal the sweep chose'
      - chooses retirement for a file a record stopped referencing
      - chooses deletion for a file no record ever committed
      - 'claims across tenants in one sweep, cleanup belonging to no tenant'
      - >-
        takes the oldest deadlines first when more rows are due than the limit, leaving the newest for the
        next sweep
      - 'claims nothing that is not due yet, an in-flight save never losing its file'
      - 'answers an empty list, and writes nothing, when the selection finds no row to claim'
    skipped_declarations: []
    dependency_summary: >-
      The prepared PostgreSQL database and its declared constraints, policies and transaction behavior are
      real dependencies.
command: >-
  npx mocha --timeout 60000 'modules/entities/tests/integration/db/managed-assets.table.integration.spec.js'
  'modules/entities/tests/integration/services/assets/asset-cleanup-handler.integration.spec.js'
  'modules/entities/tests/integration/services/assets/clean-up-entity-assets.integration.spec.js'
  'modules/entities/tests/integration/services/assets/entity-asset-reads.integration.spec.js'
  'modules/entities/tests/integration/services/assets/record-assets-adoption.integration.spec.js'
  'modules/entities/tests/integration/services/assets/retire-entity-assets.integration.spec.js'
  'modules/entities/tests/integration/services/persistence/managed-assets.integration.spec.js'
  'modules/entities/tests/integration/services/persistence/managed-assets.queries.integration.spec.js'
retries: none
proves_controls: []
product_intent: proposed
implementation_evidence: observed
narrative:
  status: enriched
---

# Managed record assets integration

## Verification Boundary

Managed-asset lifecycle rows, record reference adoption, cleanup scheduling and cleanup consumption cross local storage-adapter and PostgreSQL seams.

## What This Suite Proves

The tests distinguish prepared objects from attached and retired assets, verify read authorization decisions and establish reuse, cleanup eligibility, record adoption and abandoned-object handling. Cleanup failure tests retain rows or repair references according to the actual outcome. The managed-asset table tests refuse malformed lifecycle rows by named constraints.

## What It Does Not Prove

In-memory storage cannot prove deployed S3 permissions, bucket retention or queue delivery. Record field validation belongs to the contact and organization groups. A settled local cleanup consumer does not demonstrate a deployed schedule firing or an alarm detecting exhausted work.

## Level and Rationale

The prepare-before-commit pipeline can leave objects outside a record transaction. Only an integration boundary can establish the relationship between attached record values, managed rows and cleanup work after failure. A unit test of a state label cannot establish safe physical-object retirement.

## Real Dependencies and Test Doubles

PostgreSQL and the SQL lifecycle store are real. The source-declared in-memory asset store preserves versions and pins; the import cases use a local HTTP server and resolver. Cleanup queue and storage failures are supplied at explicit seams, so the observed proof concerns compensation rather than AWS connectivity.

## Must Not Be Doubled

Never replace the SQL lifecycle store, record-reference inspection or the cleanup decision under test. Preserve object version behavior in the storage double. A permissive fake copy that ignores version changes could let a stale object appear safely adopted.

## Fixtures and Isolation

The group creates its own objects and managed rows, and makes only its own rows due for cleanup. This matters because the sweep claims due work across workspaces. Cleanup restores the pipeline dependencies and removes owned rows; running serially avoids process-wide storage injection conflicts.

## Determinism and Retries

Object keys and managed identities use scoped fixtures. Eligibility windows and deliberate failure outcomes are supplied explicitly rather than waiting for an external service. No retries are added by the register: abandoned or unprocessed rows are asserted as the actual failure result.

## How It Runs

Run the command in the frontmatter from the service repository after preparing the test database. It selects every file in this aggregate register under the repository Mocha bootstrap and runs serially. This is the aggregate selection of the integration gate; the complete repository integration command includes other modules and is wider than this register.

## Coverage Gaps

Local cleanup coverage leaves deployment grants, schedule cadence and dead-letter behavior for the component and resource pass. This integration register supplies no lifecycle proof through a public record API by itself. An asset cleanup is not a contact delete lifecycle, even when deletion makes an asset due.
