Data model
Every custom table CaptainCore creates, what it holds, how model classes map to tables, and how schema migrations run.
CaptainCore does not use custom post types. It creates its own tables and treats them as the fleet’s state. Models in app/ are thin classes over those tables sharing one query layer, autoloaded through Composer PSR-4.
All tables are created and migrated in CaptainCore\DB::upgrade() with dbDelta, using $wpdb->base_prefix, so on multisite there is one shared set of tables rather than one per site.
How models map to tables
DB::_table() derives the table name from the class name. It splits the fully qualified name on the namespace separator, lowercases the namespace, converts the class name from CamelCase to snake_case, and joins the two with an underscore, then prefixes it with $wpdb->prefix.
So CaptainCore\Sites becomes wp_captaincore_sites, and CaptainCore\SiteAuditFindings becomes wp_captaincore_site_audit_findings. No mapping table, no configuration.
Each model declares a static $primary_key, which is what get(), update() and delete() key on:
| Class | Table | Primary key |
|---|---|---|
Sites |
captaincore_sites |
site_id |
Environments |
captaincore_environments |
environment_id |
Accounts |
captaincore_accounts |
account_id |
AccountPortals |
captaincore_account_portals |
account_portal_id |
AccountUser |
captaincore_account_user |
account_user_id |
AccountSite |
captaincore_account_site |
account_site_id |
AccountDomain |
captaincore_account_domain |
account_domain_id |
Domains |
captaincore_domains |
domain_id |
Providers |
captaincore_providers |
provider_id |
ProviderActions |
captaincore_provider_actions |
provider_action_id |
Captures |
captaincore_captures |
capture_id |
Snapshots |
captaincore_snapshots |
snapshot_id |
Processes |
captaincore_processes |
process_id |
ProcessLogs |
captaincore_process_logs |
process_log_id |
ProcessLogSite |
captaincore_process_log_site |
process_log_site_id |
ProcessLogFile |
captaincore_process_log_file |
process_log_file_id |
Recipes |
captaincore_recipes |
recipe_id |
Scripts |
captaincore_scripts |
script_id |
Keys |
captaincore_keys |
key_id |
Invites |
captaincore_invites |
invite_id |
ScheduledReports |
captaincore_scheduled_reports |
scheduled_report_id |
WebRiskLogs |
captaincore_web_risk_logs |
web_risk_log_id |
JobTokens |
captaincore_job_tokens |
job_token_id |
SecurityThreatTracking |
captaincore_security_threat_tracking |
security_threat_tracking_id |
ActivityLogs |
captaincore_activity_logs |
activity_log_id |
SecurityPatch |
captaincore_security_patch |
security_patch_id |
SiteAudits |
captaincore_site_audits |
site_audit_id |
SiteAuditFindings |
captaincore_site_audit_findings |
site_audit_finding_id |
SessionSnapshots |
captaincore_session_snapshots |
session_snapshot_id |
CoreUpdateRuns |
captaincore_core_update_runs |
core_update_run_id |
CoreUpdateResults |
captaincore_core_update_results |
core_update_result_id |
Not everything in app/ is a table. Site, Account, Domain, Provider and User are single-record wrappers with business logic. Quicksave, Run and the Remote\* classes talk to the CLI or third-party APIs and store nothing locally.
Sites and environments
captaincore_sites
One row per managed site.
site_id, account_id (who is billed), customer_id (who owns it), name (the domain), site (short slug), provider_id, provider_site_id, provider, token, status, details (longtext JSON), screenshot, created_at, updated_at.
details is where most of the interesting state lives: cached core version, home URL, subsite count, removal flag and site labels.
captaincore_environments
One row per environment on a site, typically Production and Staging.
Connection: environment (the name), address, username, password, protocol, port, home_directory, token.
Database: database_name, database_username, database_password.
Offload storage: offload_enabled, offload_provider, offload_access_key, offload_secret_key, offload_bucket, offload_path.
Inventory: themes, plugins, users (all longtext JSON), core, core_verify_checksums (default 1), subsite_count, home_url, php_memory, storage, visits, fathom.
Behaviour: capture_pages, screenshot, monitor_enabled, updates_enabled, updates_exclude_themes, updates_exclude_plugins.
details (longtext JSON) carries everything the sync collects that does not have its own column: core_checksum_details, plugin_checksum_details, mu_plugins, core_file_hashes, loose_file_hashes, security_log, error_logs, php_version, db_size, default_role, registration, restic_cache, screenshot_base, audit_summary, and the one-shot alert flags (checksum_alerted, default_role_alerted, registration_role_alerted).
Accounts and permissions
captaincore_accounts
account_id, billing_user_id, name, defaults (JSON: default email, timezone, recipes, users applied to new sites), plan (JSON), metrics, status.
captaincore_account_portals
White-labelled customer portals. account_portal_id, account_id, domain, configurations (JSON: name, colors, logo, DNS copy). Matched against the CAPTAINCORE_CUSTOM_DOMAIN constant at runtime.
The three join tables
captaincore_account_user carries account_id, user_id and level (full-billing, full, sites-only, domains-only). captaincore_account_site and captaincore_account_domain are plain joins. Together these decide what every non-administrator can see.
captaincore_invites
Pending account invitations. account_id, email, token, level, and an accepted_at timestamp.
captaincore_keys
SSH public keys. user_id, title, fingerprint (varchar 47), main boolean for the primary key.
Domains
captaincore_domains
domain_id, remote_id (the Constellix zone id), provider_id and provider_domain_id (the registrar), status, price, name, details (JSON: nameservers, WHOIS contacts, lock and privacy state, Mailgun and forwarding configuration).
History and history-like tables
captaincore_captures
Visual capture history. site_id, environment_id, git_commit, pages (JSON array of {name, image}), indexed on (site_id, environment_id).
captaincore_snapshots
Downloadable full-site snapshots. user_id, site_id, environment_id, snapshot_name, storage, email, notes, expires_at and a token (varchar 32) used in the public download URL.
captaincore_session_snapshots
Append-only per-environment user and privilege telemetry, one row per environment per sync.
Summary columns for querying: collected_at, total_users, session_token_rows, admin_users, admin_sessions, admin_unique_ips, admin_capable_users, injected_caps_count, super_admin_count.
Detection columns: anomaly_count, max_severity (default none), anomalies (JSON), alerted_at (null until the hourly digest picks it up).
payload holds the full collector JSON. Indexed on site_id, environment_id, created_at, injected_caps_count, max_severity and alerted_at.
SessionSnapshots::latest_for() deliberately orders by the auto-increment primary key rather than created_at, because two snapshots can share a timestamp and the delta baseline must be the genuinely previous row.
Process logs
captaincore_processes
Handbook processes: name, description, time_estimate, repeat_interval, repeat_quantity, roles.
captaincore_process_logs
One recorded run of a process, or a freeform note. process_id, user_id, description (Markdown), public boolean, status, and completed_at.
captaincore_process_log_site
Which sites a log entry is attached to, indexed on site_id. This is what puts a note on a site’s timeline.
captaincore_process_log_file
File-level diffs attached to a log entry. file_path (varchar 512), change_type, hunks (longtext), lines_added, lines_removed.
Automation
captaincore_recipes
Saved scripts. user_id, title, content, public boolean. public is administrator only to set.
captaincore_scripts
Scheduled code against an environment. environment_id, code, details, status.
captaincore_job_tokens
Maps a CLI job token to the user who created it. token (varchar 64, unique), task_id, user_id, site_id, command, indexed on user_id. This is what makes GET /my-jobs/{token} refuse someone else’s token.
captaincore_scheduled_reports
site_ids (longtext), account_id, interval, recipient, user_id, next_run, last_run. Note that interval is a reserved word and is backticked in the schema.
Providers
captaincore_providers
One row per connected hosting or registrar account. user_id (0 means the shared house row), name, provider (the adapter slug), status, details, credentials (JSON array of {name, value}), configurations.
captaincore_provider_actions
The provisioning chain. provider_id, provider_key, user_id, status, action (JSON describing the step). Long provider operations, creating a site, cloning, deploying, are stepped through here rather than held open in one request.
Security operations
captaincore_security_threat_tracking
slug, version, type, status (default new, moving through investigating, reported, resolved), notes, resolved_at. Unique on (slug, version, type), indexed on status.
captaincore_security_patch
Rebuilt packages that fix a vulnerable build. slug, version, type (default plugin), title, patched_version, download_url (varchar 500), description, severity. Unique on (slug, version, type).
captaincore_site_audits
Stored reports. site_id, environment_id, status (default in_progress), filesystem_status, wp_version, php_version, issues_count, plugins_count, scan_checks, site_config, admin_accounts, timeline_events, user_id, notes, completed_at, report_path, report_type (default security_audit), dashboard_metrics, summary, sections, report_title, section_order. Indexed on site_id, environment_id, status and created_at.
captaincore_site_audit_findings
site_audit_id, severity, status (default open), title, description, evidence, recommendation, resolution (varchar 512), resolved_at.
captaincore_web_risk_logs
One row per Google Web Risk fleet run. total_sites, threats_found, errors_count, details.
captaincore_activity_logs
The audit trail. Covered in detail in Activity log.
Fleet core updates
captaincore_core_update_runs
The parent row for one probe or apply pass: target, flags, version_requested, version_resolved, parallel, duration_seconds, total, updated_count, skipped_count, failed_count, probed_count, status (default completed).
captaincore_core_update_results
One row per site in that run: core_update_run_id, site, site_id, environment_id, home_url (varchar 500), result, action, stage, core_before, core_after, reason, excerpt, exit_code, error_class, status (default open, for triage) and notes. Indexed on run, result, stage, error class, status and site, so failures can be grouped by shape.
Schema version and migrations
The schema level this build expects is a constant:
class DB {
/** Schema level this build expects. Bump when a migration is added. */
const REQUIRED_VERSION = 52;
The current level is stored in the captaincore_db_version site option. DB::upgrade() returns early if the stored version is already at or above REQUIRED_VERSION, unless called with $force = true.
Migrations run automatically. captaincore_maybe_upgrade_db() is hooked to both init and admin_init, because the four ways CaptainCore is used, the /account front-end rewrite, the CLI ingest, the fleet cron shelling out to wp captaincore, and wp-admin itself, do not all reach admin_init. Without the init hook a schema release could sit un-migrated until somebody happened to open wp-admin.
The function guards carefully, in this order:
- WP-CLI is allowed through unconditionally: it has no user context and is already root-equivalent.
- Everything else bails on AJAX, cron, or a logged-out request, then requires
manage_networkon multisite ormanage_optionsotherwise. Bothinitandadmin_initrun before the authentication check inadmin-ajax.php,admin-post.phpand the REST API, so without this an unauthenticated request could trigger the migration. - If the stored version already meets the requirement, it returns.
- It takes a lock with
add_site_option( 'captaincore_db_upgrade_lock', time() ).add_site_optiononly succeeds when the option does not exist, so exactly one request takes it. A lock older than 15 minutes is reclaimed, so a fatal error mid-migration cannot wedge upgrades permanently. - The upgrade runs inside a try/finally with output buffering, so a
dbDeltawarning cannot corrupt the page it fired on and a failure cannot white-screen the dashboard. The lock is always released. - Success is determined by re-reading
captaincore_db_version, not by parsing the return string. A shortfall is written to the error log.
DB::upgrade() returns a string rather than echoing, because it runs from the activation hook where stray output makes WordPress report unexpected output.
To run a migration by hand:
wp eval 'echo CaptainCore\DB::upgrade();'
One data migration ships alongside the schema. At version 39, account owners were backfilled into captaincore_account_user.level as full-billing from each account’s plan.billing_user_id, and every remaining blank level was set to full.