Local service startup
- Start Postgres.
- Start
stralos-api-globaldependencies with DynamoDB Local and initialise OTP/session tables. - Run migrations for the API services you need.
- Start API services on ports 3300 to 3304.
- For Documents, start LocalStack and
pnpm dev:worker. - Start
stralos-shell.
Database migration recovery
- Confirm the service
.envpoints at the intended database. - Run
pnpm db:generateonly when schema changes are intended. - Run
pnpm db:migrate. - If a migration fails, inspect the generated SQL and database state before retrying.
Documents API: org-security M4 migration (api-documents)
The M4 rollout forstralos-api-documents uses three migration phases followed by an automatic resolver bootstrap. Run the package entrypoint:
scripts/migrate-orgsec-waves.mjs and then scripts/bootstrap-doc-resolver.mjs.
Phase 1 — wave 1 (migration 0008)
Migration 0008_orgsec_m4_add_org.sql adds nullable organisation_id columns, composite parent uniques, and supporting indexes.
Phase 2 — wave 3 gate (populated environments only)
If any of the nine gated tables contain null organisation_id values, the migration runner stops before applying wave 5. Wave 1 is committed but wave 5 is not applied. The operator must stamp the null rows directly in the documents database, then re-run pnpm db:migrate to resume. The gate is skipped on a fresh or empty database.
Phase 3 — wave 5 (migration 0009)
0009_orgsec_m4_constrain_rls.sql sets organisation_id to NOT NULL on the nine gated tenant tables, replaces single-column foreign keys with composite ones, and recreates business-unique indexes as org-composite. It enables and forces RLS on all twelve organisation-bearing tables. The migration opens with an in-database guard that raises if any required organisation value remains null.
Phase 4 — bootstrap the org resolver
The package script automatically runs scripts/bootstrap-doc-resolver.mjs after the migration choreography. The owner connection temporarily grants the required privileges, the platform connection creates and owns resolve_org_for_document, and the temporary schema privilege is then revoked.
Run the bootstrap directly only to recover a missing or dropped function:
Troubleshooting
- If wave 5 fails because the wave 3 gate found null organisation values, stamp the affected rows directly in the documents database, then re-run
pnpm db:migrate. The choreography script resumes from the last recorded migration. DATABASE_URLmust be set explicitly. A missing value exits before touching a database.- If the resolver bootstrap fails, confirm
PLATFORM_DATABASE_URLtargets the same Documents database using the platform role, then runpnpm db:migrateagain. The migrations are resumable and the bootstrap is idempotent. - Do not run
drizzle-kit migratedirectly against a database where 0008 and 0009 are both pending. Usepnpm db:migrateinstead.
Documents API: project-membership migration (api-documents)
Migration0014_project_membership.sql adds restrictive project-membership RLS policies to every table in stralos-api-documents that carries a project_id column. The migration is marked CUTOVER_REQUIRED because old API instances lack the actor and project scope that the new policies expect.
Before you start
- Old API instances cannot serve traffic across this migration. They lack
app.project_idsand will fail closed under the new restrictive RLS. - Workers also require the new code path (
runScopedMessage/runScopedMaintenancefromsrc/workers/projectScope.ts). Old worker binaries drop messages in terminal error rather than processing them.
Migration steps
- Stop all API Lambda and worker Lambda invocations for the Documents service (set reserved concurrency to zero or pause the SQS event sources).
-
Apply the migration:
-
Deploy the new application code (API and workers) that sets
app.project_ids,app.estimate_ids, andapp.worker_scopeon worker connections and the authenticated actor scope on API connections. - Resume API and worker invocations.
-
Confirm processing events are being written by monitoring the
documents_processing_eventstable or CloudWatch worker metrics.
Documents migration changes
- Adds a
project_scope_allows(project_id)SQL function that checks whether a given project ID appears inapp.project_ids. - Adds restrictive
project_membershippolicies on everyproject_id-bearing table, enforced forstralos_runtime_documents. - Walks FK descendants transitively to add
project_parent_<fk_oid>policies on child tables that reference a project-owning parent. - Patches
project_document_eventondocuments_processing_eventsto also allow writes wheredocument_id IS NULL AND app.worker_scope = 'true', so sweep and sync-dispatch workers can write events for null-project documents during cutover. - Adds
project_assessmentondocuments_variation_assessment_jobskeyed onapp.estimate_ids. - Adds
project_outbox_parentondocuments_outboxusing FK-linked document or variation-job ownership.
Recovery after a failed cutover
Follow the API writer cutover general recovery procedure. The migration is applied atomically; a failure leaves the schema in its pre-migration state. Do not reopen traffic until the code and schema are confirmed to be consistent.DWG demand and conversion spend report
Thereport-dwg-demand.mts script in stralos-api-documents produces a combined view of DWG/DXF upload demand and APS Design Automation conversion spend. Run it from the repository root:
PLATFORM_DATABASE_URL to the platform (BYPASSRLS) connection before running. DATABASE_URL is accepted as a fallback only when it points at the platform role.
Demand section
The demand section counts DWG and DXF files that reached the server and were refused:- Upload — presign attempts where
validateFilerejected the file. Recorded as astatus='rejected'document row. - Sync — drive-sync crawl entries skipped as an unsupported type. The filter uses the file name extension, not the skip reason, to isolate CAD files from other unsupported formats.
Spend section
The spend section totals APS Design Automation processing time for conversions that were actually run. The output reports processing time in both milliseconds and minutes for comparison with Autodesk usage reporting. Allowances, metered charges, and the response to reaching a limit depend on the account’s current Autodesk tier; confirm them in the Autodesk developer hub and current rate table before making an operational decision. The total is a floor, not an exact count. Two cases produce spend that cannot be measured from the stored record:- A retry replaces the previous attempt’s metadata, so failed attempts before a final success or terminal failure carry no duration. Their count is reported as a range (
unmeasuredAttempts min–max). - A settle that never polled a workitem — submit failure, orphaned claim, wall-clock deadline, DLQ exhaustion — cannot confirm whether a workitem was created and billed.
unmeasuredAttempts. A gap larger than that bound is unexplained.
Spend by month covers measured durations only. An erased prior attempt carries no timestamp, so it is counted once in the totals and never attributed to a calendar month it may not belong to.
Documents API: page render and scale backfill (api-documents)
The Documents pipeline renders every page image and thumbnail ahead of time. New uploads are covered by their upload paths and the sweep’s safety nets. Documents uploaded before ahead-of-time rendering have no pre-rendered pages, soscripts/backfill-page-renders.mts in stralos-api-documents queues the missing renders once per environment. With --scale, it also queues scale detections for historic drawings.
Prerequisites
- AWS credentials for the target environment. The script uses the
stralos-<env>profile unlessAWS_PROFILEis set. UnsetAWS_ENDPOINT_URL; the script refuses to run while it is set. PLATFORM_DATABASE_URLset to the documents platform role’s connection string, reached through the bastion. The script refuses a role that cannot see every organisation. The script header shows the port-forwarding command.
Dry run, then execute
Run from the repository root. The default is a dry run, which reads only: it reports how many documents need work and an estimated Lambda cost, and it claims and queues nothing.Safety guards
The script reads the bucket and queue names from the deployed render worker’s own Lambda configuration. It refuses to run when the database’s documents live in a different bucket, so a dev database pointed at prod queues (or the reverse) stops before anything is sent. All work goes to the render worker’s bulk queue, whose maximum concurrency of 10 is the throttle. The bulk queue never sits in front of a page someone has open, which uses the interactive queue.Scale detection backfill (--scale)
With --scale, the script also queues a scale detection for every page of a historic drawing that has no scale and no cached detection result. Pages that already have a scale, a cached result, a detection in flight, or a failed detection are left alone.
A document counts as a drawing when any of these holds, and it was never rejected for takeoff:
- It carries a render-time drawing marker (the takeoff import marker, a converted CAD drawing, or the classifier’s drawing marker).
- It has been approved for takeoff.
- Its takeoff run completed, and it is not a Drive sync new version.
API writer cutover
Activities, Assistant, Documents, Estimate, Global, Rates, and Scheduler deploy migrations online by default. Their pinned shared workflow readsdrizzle/CUTOVER_REQUIRED and versioned drizzle/<nnnn>_CUTOVER_REQUIRED markers, then checks the migration ledger for pending named migrations. The ordinary pull-request guard still requires the unversioned form in every service except Rates.
Arm the outage for one release
-
Add the numbered migration file under
drizzle/. -
In the same pull request, create
drizzle/CUTOVER_REQUIREDwith no extension. Each non-blank, non-comment line names the migration file as it appears indrizzle/:A filename must match^[0-9]{4}_[A-Za-z0-9_]+\.sql$and the named file must exist. A malformed or empty marker, an unknown filename, an unreadable migration tree, or a symlink fails validation. Deployment does not infer that writers can remain online from missing or invalid marker evidence; correct the marker before deploying. Rates also accepts a versioned marker such asdrizzle/0036_CUTOVER_REQUIRED, with the same four-digit prefix as its migration. The other six pull-request guards require the unversioned form. Both forms are visible to the pinned deployment workflow. -
Run the pull-request guard locally:
The guard prints
ARMEDwhen a marker names every changed migration containing a guarded destructive statement. A clean additive change printsCLEAN; any other result is a refusal with the detected statements and marker problems. -
Confirm the pull-request guard and the ordinary migration validation pass before merging. The shared deployment workflow reads the marker from the exact deployed source;
MIGRATION_MANIFEST.jsonremainsadditive-onlineand does not arm the outage. - Confirm that completion is recorded for each named migration in every target environment. Markers can remain in source because a later release subtracts migrations recorded as completed by another release in that environment. A retry of the same release retains its outage, and unreadable ledger evidence retains arming. Any commit that changes or removes a marker also refuses automatic dev deployment; use a reviewed manual dispatch for that range.
Recover after a failed cutover
Once the cutover step has stopped writers, a later migration or Terraform failure deliberately leaves them stopped. Do not reopen traffic until the deployed code and migration state are understood.- Inspect the failed workflow’s Cutover writer recovery step summary. It records the function name, the observed prior concurrency, and the exact restoration command.
- Inspect the migration ledger and database state to determine whether each migration completed. Do not rerun a partially applied destructive migration without reviewing its effects and recovery path.
- Recover or complete the deployment while writers remain stopped.
- Only after the code and schema are safe together, run the recorded restoration command. It restores the prior reserved concurrency or removes the reservation when the function was previously unreserved.
- Confirm the request Lambda is serving normally before ending the incident. A successful restoration does not by itself prove the migration or deployment completed.
Rate import: stuck job investigation
A rate import job showsrunning indefinitely when all its blocks have finished processing but the job status has not settled, or when a block was never processed.
Check job and block state first.
- Retrieve the job via
GET /api/rates/rate-imports/:jobId. Thestatusfield and theblocksarray show the current state of each block (pending,running,done,empty, orfailed). - A job stuck in
runningwith all blocks in terminal states (done,empty, orfailed) indicates the job aggregation step has not run. This is self-resolving; wait for the next SQS redelivery or contact an engineer if it does not settle within the visibility timeout window. - A block stuck in
pendingwith no recent activity means its SQS message was never delivered or was delivered and exhausted its retry budget.
failed with error code dead_letter. This prevents the job from polling running indefinitely. The worker logs a SUPPORT_ALERT entry in CloudWatch before acknowledging the message.
To find the forensic record in CloudWatch:
- Log group:
/aws/lambda/stralos-<env>-rate-import-worker - Filter:
{ $.marker = "SUPPORT_ALERT" && $.kind = "rate_import_dead_letter" }
messageId, jobId, blockId, and organisationId from the original message.
A block already in done, empty, or failed is never overwritten by the dead-letter drain. If a block shows a successful state but the message also appeared in the dead-letter queue, the extraction completed and the acknowledgement was lost. The result is intact.
Retrying a failed job.
Use POST /api/rates/rate-imports/:jobId/retry to re-dispatch blocks for a failed job. The retry route resets the job to running before re-dispatching, so the dead-letter drain does not interfere with a deliberate retry.
Documents API: Lambda connection pool limits (api-documents)
Each Lambda container that serves the Documents API creates up to three Postgres connection pools (owner, runtime, and platform roles). Each pool is capped atmax: 2 with a connectionTimeoutMillis of 5000 ms.
The cap prevents a burst of warm containers from exhausting a database’s connection slots. Without it, a fleet of containers each holding up to ten connections can claim far more connections than the database allows, causing every API request to fail with a 500 for the duration.
When a pool cannot acquire a connection within 5 seconds, the request fails immediately. The safe error handler maps this to a 503 with code: "service_unavailable", a reference matching the request’s X-Request-ID header, and a Retry-After: 5 header. Clients should retry after that interval.
The local development server and one-off scripts are not subject to this cap. They use pg’s default pool size of 10, which lets a single local process handle concurrent requests without serialising them.
Diagnosing a 503 from the Documents API
- A
503withcode: "service_unavailable"fromapi-documentsmeans the pool timed out waiting for a Postgres slot. - Check the current database connection count against
max_connectionsandpg_stat_activityto confirm saturation. - If Lambda concurrency is unexpectedly high, check for a processing backlog or a runaway retry loop that is holding connections open.
- The
referencevalue in the response body maps to a log entry in the Lambda’s CloudWatch log group underx-request-id.
Document pipeline queue inspection
- Check document status first through
GET /api/documents?estimateId=.... - Check area runs and pending suggestion counts.
- Inspect LocalStack or AWS queues for stage backlog.
- Inspect DLQs before manually retrying documents.
Failed document retry
- Retry only documents in
failed. - If the upload never completed, upload the file again.
- If scan failed or the file is infected, do not bypass the download gate.
- After retry, poll status and review area run errors.
Estimate totals recompute
UsePOST /api/estimate/estimates/:id/recompute-totals to recalculate an affected estimate from its materialised pricing inputs. The endpoint holds the estimate-totals advisory lock and returns the updated work item list.
For legacy material inputs, scripts/materialise-pricing-inputs.mjs snapshots rate-option identity, effective unit cost, and source and location labels into keyed component overrides. DATABASE_URL is required; STRALOS_RATES_URL defaults to http://localhost:3301. Run this only after reviewing the affected rows against the current schema. It makes HTTP calls and requires a running Rates service, so do not run it inside a database migration.
An ordinary rerun skips complete overrides. Clearing or rematerialising snapshots, or filling previously incomplete rows, can change prices. Review the changes and recompute the affected estimate totals afterwards.
The legacy estimate_work_items.pricing_source column is retired. Do not run a pricing-row backfill that reads that column against the current schema.
Service token for cross-service reads
The material-input script mints aservice:pricing-materialise bearer token before reading Rates and refreshes it within 30 seconds of expiry (TTL 600 s). If minting fails, the helper attempts the request without a header, which Rates rejects. Successful minting is required for a useful run.
Service-token configuration:
The helper currently uses the local HTTP mint transport. The deployed mint uses
direct Lambda invocation instead and is not exposed through API Gateway. Do not
rely on tokenless degradation: the current Global and Rates services reject
unauthenticated protected requests. Do not commit mint secrets to source control.
Drive sync: sync folder transfer backfill
Run this once after deploying the sync folder transfer feature (api-documents
migration 0011_sync_folder_transfer.sql). The backfill seeds a new reconciliation
crawl for every resumable connection in active, paused, or error state.
current_run_id before seeding, superseding
any in-progress crawl while preserving the change cursor. It never writes a folder
directly. The new crawl re-evaluates documents that sync owns or that have never been
filed; user-owned folders remain unchanged, and an unresolvable source path preserves
the existing filing.
The script is safe to re-run. Every still-resumable connection receives a newer crawl
generation; it does not pre-scan documents to skip connections.
Drive sync: source stuck in auth_error or error
See Drive sync error classification for what moves a source into each state.- For
auth_error: confirm the provider grant is still in place (Google Drive member, SharePoint site permission) and re-add it if removed. If every source on the organisation’s drive identity moved together (identity_auth_error), check the identity’s status and reconnect it if needed. - For
error: the environment’s federation failed (platform_federation_failure, which also emits thedrive_platform_federation_failuremetric). It affects every active source for that provider. Ops must fix the drive platform configuration before recovery is possible. - Once the underlying issue is resolved, call
POST /api/documents/drive-sources/:id/verify. No new proof file is required; re-verify only re-runs the access check. - Monitor the source status and the next run’s outcome.
Drive sync: re-include a file
The sync engine links files rather than copying them, and no longer quarantines files after repeated ingest failures: it re-evaluates any olderingesting, failed, skipped or error mapping on its next pass. A file a user deleted from Stralos is excluded and stays excluded until an admin re-includes it.
- Call
GET /api/documents/sync-connections/:idand locate the file in the connection detail. - As an admin, call
POST /api/documents/sync-connections/:id/files/:fileId/reinclude. Only anexcluded,errororfailedfile can be re-included (409otherwise). The route resets the file toerror, clears its ingest budget, and seeds a reconciliation crawl. - The reconciliation re-lists the folder tree and links the file on the next run. A re-included excluded file arrives as a new document.
Drive sync: connection paused unexpectedly
A connection is fence-broken (run cleared) when its source is disabled, the connection is patched topaused, or the connection is deleted. It is also broken on a reconciliation seed.
- Check the source status —
auth_error,error, ordisabledsuspends all its mappings. Restore access and re-verify or enable the source as appropriate. - Check the connection status via
GET /api/documents/sync-connections/:id. - If the connection itself is
paused, resume it withPATCH /api/documents/sync-connections/:idand{ "status": "active" }. - Once both the source and connection are active, use Sync now from the project Documents page if you want to request the next run immediately.
Drive sync: toggle the kill switch
The drive-sync kill switch stops sync dispatch, the sync worker, and drive source and connection mutations without a deploy. See Drive sync for how the gate works. The lever is theapi-documents SSM parameter /stralos/<env>/api-documents/drive-sync-enabled. Terraform seeds it once per environment and then ignores its value, so change it with the AWS CLI, not a Terraform apply. Every Lambda reads it through the shared five-minute SSM cache, so a change reaches warm instances within five minutes. A failed read counts as closed.
Close the switch
409 drive_sync_disabled. Reads of already-synced documents are unaffected. Closed-gate invocations log a {"event":"drive_sync_disabled","stage":...} line.
Reopen the switch
Set the value back totrue. Only the exact string true opens the gate; True, true, or 1 keep it closed. The next dispatch tick after the cache refreshes resumes normal scheduling. No restart or deploy is needed.
Confirm the switch state
Sync-dispatch schedule rule
Thedrive_sync_schedule_enabled Terraform variable (default true) controls whether the five-minute sync-dispatch EventBridge rule is enabled. Setting it to false in the environment’s tfvars and applying stops even the dispatch cold starts. The SSM parameter still gates every surface on its own.
Org-security M3 backfill and migration Estimate API
After deployingstralos-api-estimate with the M3 org-security wave, run its migration choreography.
Pre-requisites
DATABASE_URLis set and points at the estimate database using the owner (migration) role.- Outside local development,
PLATFORM_DATABASE_URLpoints at the same Estimate database usingstralos_platform_estimate.
scripts/migrate-orgsec-waves.mjs and then scripts/drop-portal-resolver.mjs. On a populated database it:
- Applies migration
0015_orgsec_m3_add_org.sql(adds nullableorganisation_idcolumns). - Refuses to continue if any row still needs
organisation_idstamping. The cross-databasescripts/backfill-organisation.mjsthat once stamped them is deleted: cross-database migrations are not allowed, and every deployed environment passed0016long ago. - Applies migration
0016_orgsec_m3_constrain_rls.sql(setsNOT NULL, FORCE RLS, policies, and grants). - Drops the retired
resolve_org_for_portal_tokenSECURITY DEFINER function, if present, through the platform connection.
Step 2 — verify
Confirm the migration run completes without refusing at wave 3.
Estimate lifecycle M3 backfill
The one-offscripts/backfill-estimate-lifecycle.mjs that normalised estimate statuses after migration 0014_lifecycle_m3_estimate.sql has been deleted from stralos-api-estimate. It read the Global database directly, cross-database access is no longer allowed, and every deployed environment has already run it. Use the reconciliation report below to check for status drift.
Lifecycle reconciliation report
The reconciliation script is a read-only two-database safety net. Run it whenever you suspect cross-service drift.- Status disagreements — estimate statuses that are not in the allowed set for their project status. For example, an estimate in
draftwhen the project isawarded. - Stale pending rows —
estimate_status_historyrows (and the globalproject_status_historyrows) instate='pending'for more than 10 minutes. These are transitions the sweeper has not yet resolved. - Version mismatches — estimates whose
versionfield does not match their highest committed revision number ("0"when none exists).
1 if any section is non-empty, which ops alerting can hang off.
Environment variables
Admin API tenant bootstrap (db:init)
pnpm db:init runs on the migration rail on every deploy. It is idempotent and exits 0 in all non-error states. See Deploy-time tenant bootstrap for the full configuration reference.
Verifying a successful bootstrap
The command writes a JSON line to stdout. Acreated run includes organisationId and userId; an already_initialised run includes userId and email; a skipped run includes a reason.
Diagnosing a partially-configured error
If the command exits non-zero with a message likedb:init is partially configured; missing ..., one or two of the three required variables are set but not all three. Set all three or unset them all. A partially-set configuration is always a hard error.
Admin email already taken by another organisation
The bootstrap is keyed onADMIN_INIT_ADMIN_EMAIL. If that address already belongs to a user in a different organisation (for example after a seed mistake on a shared environment), the run returns already_initialised pointing at the existing user rather than creating a new tenant. Confirm the existing user’s organisationId in the output before deciding whether the environment needs to be reset.
Cognito account never created (local development)
Locally there is no Cognito sweeper, so the admin user’sprovisioning column stays pending and no invite email is sent. This is expected: the regional runtime authenticates from Postgres using OTP sign-in, so the administrator can log in without a Cognito account.
In a deployed environment, confirm the Cognito sweeper is running (enable_event_sync = true and cognito_user_pool_id set in Terraform) and check the cognito_operations table for a pending create row.
Admin console operator access
The admin console has no public signup or password-reset flow. A private, IAM-invoked Lambda provides the first-operator bootstrap and break-glass recovery operations. Usestralos-admin-api/scripts/admin-operator.sh from an authorised
administrator workstation.
Bootstrap the first operator
Prerequisites:- the admin API stack is deployed to the target environment;
- your AWS session can invoke that environment’s operator-operations Lambda; and
- the AWS CLI and
jqare installed locally.
otpauth://
enrolment URI. Treat the URI as a credential: enrol it immediately and never put
it in CI logs, shell transcripts, tickets, or chat. The supported flow emits the
URI once; recovery rotates the underlying secret. Bootstrap is self-disabling
once any operator exists and the operation writes an operator.bootstrap audit
record.
Recover a locked-out operator
Break-glass recovery resets the named operator’s password and MFA secret, reactivates the account, and revokes all existing sessions:otpauth:// URI and writes an
operator.break_glass_recover audit record. Apply the same credential-handling
rules. Use this only when normal operator access is unavailable.
ADMIN_ENV selects the environment used in the default Lambda name.
ADMIN_OPERATOR_FUNCTION can provide an exact function name when required.
Admin database URL rotation sync
The admin API’s database connection URL is stored as an SSM SecureString atDATABASE_URL_SSM_PARAMETER. RDS manages the master password on a 7-day rotation schedule. A secret-sync Lambda re-derives and writes the assembled URL whenever the password rotates.
How the sync runs
Three EventBridge triggers invoke the Lambda:- RDS-EVENT-0016 — RDS fires this event directly when it resets the master credentials. It is the primary trigger.
- Secrets Manager label updated — fires when the
AWSCURRENTlabel moves to the new secret version. This catches manualupdate-secretcalls that the RDS event would not see. It is a secondary belt; its behaviour for service-managed (rds!) secrets has not been observed directly. - Hourly safety-net sweep — runs on the schedule in
var.database_url_sync_schedule. This is the only trigger without a best-effort caveat and sets the worst-case recovery window if both event paths fail.
AWSCURRENT from the RDS-managed secret, assembles the connection URL, compares it byte-for-byte to the current SSM value, and writes it only when the values differ. A no-op result does not update the parameter’s version history. The Lambda also invokes once at terraform apply time to repair an already-stale parameter immediately.
Diagnosing a stale URL
If admin API Lambdas are failing 28P01 (authentication failures), the parameter may not have been refreshed after a rotation. Check the most recent sync outcome in CloudWatch:"message": "admin database-url sync". The action field is either in-sync or updated. The secretVersionId field identifies which secret version was current at sync time.
If the Lambda has not run since the last rotation, the hourly sweep will reconcile automatically. To force an immediate repair, invoke the Lambda directly:
parameter, action, and optionally reason and secretVersionId. The password and assembled URL are never logged.
Rotation during a sync attempt
The Lambda detects when the secret rotates while it is running and retries up to three times. If the secret rotates on every attempt, the Lambda throws and the next trigger (at worst, the hourly sweep) reconciles. This is logged as an error in CloudWatch and does not require manual intervention unless the error recurs.Taxonomy M13 migration (admin-api)
Migration0021_taxonomy_m13_purge_precutover_snapshots.sql purges pre-cutover seed snapshots for organisations that have not yet been provisioned. It runs automatically as part of the normal deploy migration Lambda. This section covers the quiesce precondition check and triage steps for a failed deploy.
What the migration does
The migration deletesorg_seed_snapshots rows for organisations whose status is provisioning or failed and whose snapshot contains at least one rate-category row without a parentGroup field. These are snapshots frozen before taxonomy M4, which would seed a parentless flat category tree if their provisioning event were ever delivered.
The migration does not touch:
- snapshots for
activeordisabledorganisations — their provisioning events were already delivered and the snapshot is spent history; - empty snapshots (
rate_categories = '[]') — an empty snapshot seeds nothing and is shape-indistinguishable from a post-M4 snapshot; and - any snapshot whose categories all carry
parentGroup.
audit_log with action seed_snapshot.precutover_purge and aggregate taxonomy-m13-d13. The migrate Lambda swallows RAISE NOTICE, so this is the only record of what the migration did.
Quiesce precondition
Before deleting anything, the migration checks that everyseed_template row in admin_outbox has reached a terminal status. Both pending and conflicted rows fail the check. The migration raises an exception and the deploy aborts if this precondition is not met.
The check covers conflicted rows because a dead-lettered outbox row can still be re-armed by an operator. Purging its snapshot would not neutralise the queued event, leaving the delivery vehicle intact but pointing at a deleted snapshot.
Triage: migration aborted with pending outbox rows
-
Query the outbox for non-terminal rows:
-
For
pendingrows: confirm the event flusher is running and drain the outbox. Re-run the deploy after the rows reachprocessed. -
For
conflictedrows: the dead-lettered event requires operator triage. Either resolve and re-arm the row through reconciliation (the normal recovery path) and wait for it to process, or — if the organisation is permanently abandoned — disable the organisation first. Disabling moves its status todisabled, which is excluded from the purge predicate, so the row can be leftconflictedwithout blocking the migration. -
Redeploy after all
seed_templateoutbox rows areprocessed.
Verify after deployment
Query the audit log to confirm the migration ran and review its counts:before object carries totalSnapshots and pendingSeedOutbox. The after object carries purgedPendingPrecutover (rows deleted) and precutoverOnActiveOrDisabledOrgs (rows counted but intentionally kept).
Cartage retirement audit capture (Estimate API)
The Estimate API no longer runsdb:capture-cartage-ledger from MIGRATION_MANIFEST.json. The one-off capture has been retired from ordinary deployments because the cartage-removal migration has already run. The script remains available for a deliberate manual investigation of a database that may still contain the legacy schema:
estimate_cartage_retirement_audit. On a fresh database where none of the capture tables exist, it records an explicit empty payload and exits successfully.
The four required tables are estimate_estimates, estimate_work_items, estimate_work_item_components, and estimate_work_item_pricing. If some but not all exist, the schema is partially initialised and the capture fails before any migration runs:
estimate_work_item_cartage_demands table is optional because it is intentionally absent after the retirement migration.
The command requires DATABASE_URL for the Estimate database’s migration-owner connection. It has no localhost fallback.
Data retention operations
Every service’s retention Lambda runs weekly on Sunday 15:00 UTC. See Data retention for what each purge and erasure deletes, and Retention Lambdas by service for the function names. Use credentials for the target environment’s account and regionap-southeast-2.
Check an organisation’s purge clock
Each Postgres service keeps its clock in its own table:org_offboarding in api-global and admin-api, and <service>_org_offboarding elsewhere (for example documents_org_offboarding). Query it through the platform database connection (for admin-api, its normal database connection):
disabled_observed_at. A non-null purged_at means the purge completed. No row means the sweep has not seen the organisation as disabled, or has since seen it reactivated.
Read the last sweep result
Each run logs its sweep result as a JSON line in the function’s log group,/aws/lambda/<function-name>. The result lists the organisations checked, clocks started and cleared, organisations awaiting the window, purges with row counts, and refusals or failures.
Diagnose a failed purge
- The
retentionErrors alarm fires when any organisation failed. Find the organisation ID and error in the Lambda log. - Common causes: the organisation was reactivated between the sweep’s check and the purge, the mint refused the token, api-global was unreachable, or (api-documents) S3 or Google returned a permission error.
- Fix the underlying cause. The next weekly run retries automatically; rows are kept until the purge succeeds.
Run a sweep outside the schedule
A manual sweep processes every organisation, not just one. Confirm the window and the clock state before running it in production.kind runs the sweep in every service except api-scheduler, which needs --payload '{"kind":"org-purge"}'. api-login has no sweep. Do not invoke admin-api’s retention Lambda while a deployment with a cutover marker is in flight: it is outside the cutover writer gate.
Erase a user
Runscripts/erase-user.mjs in stralos-core (see Per-user erasure). Start with --dry-run to confirm the resolved user and the function plan. If the run fails partway, fix the cause and rerun the same command: every service’s erasure is idempotent, and api-global writes the audit row only after every other service has succeeded.
To inspect one service’s erasure during an investigation, invoke its function with --payload '{"kind":"erase-user","userId":"<uuid>","requestId":"<ticket-id>"}'. The response is { service, userId, requestId, counts }; all-zero counts mean nothing was left to erase. Do not use a direct invoke of api-global in place of the script, because the audit row would not include the other services’ reports.
Change a retention window
Windows are Terraform variables:org_offboarding_purge_after_days (default 90) in the regional services, and directory_purge_after_days (default 104) in admin-api and api-global. A change needs a Terraform apply. Keep the directory window at least two weekly runs longer than every other service’s window, so the directory rows go last. Lowering a window takes effect on the next run for organisations already past the new threshold.
Terraform state checks
- Confirm the backend file matches the target environment.
- Confirm AWS account validation before apply.
- Confirm service stacks read
environment-setup/terraform.tfstate.

