usage_records — one metered observation per (deployment, resource,
period, granularity). resource_kind is the BILLING kind (droplet_hours,
spaces_gb_month, ...) so it joins straight to plan_items. sub_attribution
jsonb column present from day one for forward-compat (sub-billing) but
never written in phase 3.
metering_config — per-billing-kind granularity (daily default; flip to
hourly when fine-grained billing is wanted) + retention_days.
ArcadiaCloud.Metering:
- meter_day/1 — walks every deployment-attributed resource, maps its
inventory kind to a billing kind, records that day's usage. Idempotent
via the unique (deployment, resource, period, granularity) index.
- usage_for_period/3 — SUM(qty) GROUP BY billing kind over a date range,
returns the %{kind => qty} map the quote engine takes as :usage.
Uniform accrual model so everything downstream is just SUM(qty):
- droplet -> droplet_hours, 24/day when active (0 when off)
- spaces_bucket / snapshot / droplet_backup -> *_gb_month, size/days so
the month sums to GB-months
- dns_zone -> dns_zones, 1/days so the month sums to the zone count
MeteringWorker — Oban cron 01:10 UTC daily, meters the previous
complete day.
Smoke verified: a pilot deployment with 2 droplets + 2 DNS zones + 3
snapshots attributed; 10 days metered -> 70 usage records; aggregation
gave droplet_hours 480 (2x24x10), dns_zones 0.65 (2x10/31),
snapshot_gb_month 15.48; fed into the quote engine against Studio — all
within allowance so $50 base, no overage (correct for a light partial
month).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cloud_deployments — the billable unit (one app instance). A tenant has
1..N deployments; cloud_resources.deployment_id ties resources to one.
Fields: tenant_id, slug (unique per tenant), display_name, region,
state, llm_mode, billing_action_suspended (operator override),
template_code/version (nullable — formal templates land in phase 4).
Lifecycle state machine in ArcadiaCloud.Deployments — states trial /
active / past_due / paused / suspended / cancelled / archived. Every
transition is validated against an explicit @transitions map and
recorded in cloud_deployment_events. create_deployment defaults to
`active` (trial is wired but no flow enters it yet).
subscriptions — one per deployment, binds it to a plan_version. status
active/paused/cancelled, current period dates, trial_ends_at.
subscription_addons — addons attached to a subscription with price + qty
SNAPSHOTTED at attach time, so a later catalog price change can't
retroactively reprice an existing subscriber.
ArcadiaCloud.Subscriptions context: create_subscription (period defaults
to current calendar month), attach_addon (snapshots from the live Addon),
change_plan_version (migrate to a new version — price changes / up-down
grades), get_subscription_for_deployment.
API (platform_admin sees all tenants; others scoped to own tenant_id):
- GET/POST /api/v1/deployments
- GET /api/v1/deployments/:id (with subscription + events)
- POST /api/v1/deployments/:id/transition
- POST /api/v1/deployments/:id/subscribe (plan_code + optional addons)
Smoke verified: created a deployment, transitioned active->paused
(events logged with actor), rejected an invalid paused->archived
transition (422), subscribed to Studio with the storage_50gb addon —
addon price snapshotted at 750c/qty 50; show returns deployment +
subscription + event history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five catalog tables:
- plans — plan identity (code, name); the stable thing.
- plan_versions — versioned pricing (base_price_cents, currency,
status draft/active/retired). A subscription binds
to a version; raising prices = publish a new
version, existing subs unaffected until migrated.
- plan_items — what a version includes per resource_kind, plus
overage terms (overage_unit, overage_price_cents,
hard_cap_qty).
- addons — a la carte upgrades (code, resource_kind, qty,
price_cents).
- resource_prices — effective-dated fallback per-unit pricing for
ad-hoc items not covered by a plan.
ArcadiaCloud.Catalog context: plan + version CRUD, active_version/1
(what a new signup gets), publish_version/1 (retires the prior active
version transactionally then activates the new one),
current_resource_price/2 (effective-dated lookup).
Seed (priv/repo/seeds/catalog_seed.exs, idempotent) creates three AUD
plans — Starter $20, Studio $50, Pro $120/mo — with included
droplet_hours / spaces_gb_month / snapshot_gb_month / bandwidth_gb /
dns_zones (and LLM token allowances on Studio + Pro), plus three storage
/ LLM addons. Prices are placeholders to tune against real DO COGS once
the cost-vs-revenue dashboard lands.
API (authenticated tenants — the catalog is what they pick from):
- GET /api/v1/catalog/plans — plans with active version + items
- GET /api/v1/catalog/addons
Smoke verified: seed creates 3 plans + 3 addons; endpoints return the
shaped catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compares cloud_provisioned.spec (what we asked DO for) against the live
cloud_resources row (what DO actually has). Any divergence becomes an
operator-resolvable drift record.
cloud_drift table: one row per drifted field. status open/accepted/
reverted/stale. Partial unique index keeps at most one OPEN drift per
(resource, field); resolved rows are retained as history.
ArcadiaCloud.Drift context:
- detect_all/0 — sweeps every provisioned resource. Per field in spec,
resolves the actual value (top-level schema field first, then attrs),
compares with loose equality (stringified scalars; lists as sets so
JSON round-trips don't false-positive). Mismatches upsert a cloud_drift
row + emit a drift_detected event in the resource event log.
- close_stale_drift — an open drift whose field no longer mismatches
(fixed elsewhere) closes as "stale" on the next sweep.
- accept_drift/2 — the live value becomes the new desired-state: parent
cloud_provisioned.spec is updated, spec_version bumped, drift closed
"accepted". Revert (mutating live infra back to spec) is intentionally
NOT here — it needs a saga and lands with the droplet-resize work.
DriftDetectionWorker — Oban cron at :20 past the hour, offset past the
:15 resource syncs so it compares against fresh inventory.
Provisioning.record_provisioned/3 — populates cloud_provisioned desired-
state (upsert on resource_id, bumps spec_version). Future provisioning
sagas call this; for now it's how drift gets something to detect.
API (platform_admin only):
- GET /api/v1/drift — open drift inbox
- POST /api/v1/drift/:id/accept — adopt live value as desired-state
Smoke verified: recorded a droplet's desired spec with a deliberately
wrong size_slug + a correct region; detect_all flagged only size_slug,
wrote the drift_detected event; accept updated the spec to the live
value and closed the drift; re-detect found zero drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The architectural spine of every write workflow phase 2+ — provisioning,
suspension, offboarding, updates, rollback — all ride this engine. Each
becomes a step list, not new orchestration code.
Schemas:
- saga_runs — kind, status (pending/running/completed/failed/
compensating/rolled_back), step_modules, current
step idx, accumulating context (jsonb), cancel
flag, error.
- saga_step_results — per-step audit ledger: status (running/completed/
failed/compensated), output, attempts, timings,
unique (saga_id, step_idx).
- cloud_provisioned — desired-state for resources WE provisioned. spec
+ spec_version + saga_id; FK to cloud_resources.
Phase 2's drift-detection diff lands here.
Step contract (ArcadiaCloud.Provisioning.Step):
- execute(state) -> {:ok, state} | {:error, reason}
- compensate(state) -> :ok | {:error, _} (optional)
- name() -> String.t()
SagaState carries the live execution state — accumulating context,
immutable inputs, current step_idx. Helpers: get_output/put_output
(context r/w), get_input (inputs read-only).
Runner (Oban worker, queue: provisioning, max_attempts: 1):
- kick_off: pending -> running, run_step(0)
- run_step: idempotent re-entry on saga.current_step_idx; persists step
result + saga context after each step; recursive forward walk through
the whole step list within one perform/1 call.
- safe_execute: try/rescue/catch around module.execute so a raised
exception triggers compensation rather than blowing up the worker.
- start_compensation: status=compensating, walk from idx-1 down to 0,
calling compensate/1 where it's exported; logs but doesn't halt on
compensate failure (best-effort + audit log).
- cancellation: checked between steps; cancel_requested=true -> trigger
compensation from current idx.
- crash recovery: max_attempts: 1 + run_step keyed on
saga.current_step_idx means Oban requeue picks up at the right place,
but full crash-resume infra is deferred to phase 2.5 (manual re-enqueue
works for now).
Two proof-of-concept steps (Steps.Echo, Steps.Fail) demonstrate the
engine without any DO API exposure. First real DO write step lands in
the next chunk.
Provisioning context provides start_saga/1, list_sagas/1,
list_step_results/1, cancel_saga/1, upsert_step_result/3.
Live smoke verified end-to-end:
- [Echo, Echo, Echo] happy path: all 3 completed, context accumulated
echoed_at_step_0/1/2 = "hello".
- [Echo, Echo, Fail] failure path: step 2 failed, compensation walked
back through step 1 then step 0; final status rolled_back with error
{compensate_from_idx: 1, reason: "step_failed:fail"}; ledger shows
echo/echo/fail with statuses compensated/compensated/failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After InvoiceIngestWorker writes cost lines and matches resources, it
pushes a normalized invoice payload to skyai-finance-svc's new endpoint
POST /api/v1/integrations/cloud/import-invoice. Idempotent — finance
dedups on (tenant_id, source, external_id), so re-runs return "updated"
not duplicate rows.
ArcadiaCloud.Integrations.SkyaiFinance is the HTTP client. Auth is a
short-lived JWT minted via Guardian (shared secret per memory) with
identity {tenant_id: "platform-admin", roles: ["admin"]} — the role
satisfies finance's RequireWriteRole plug. Identity + base URL are
configurable; SKYAI_FINANCE_URL env var can override the default
http://localhost:4010 for arcadia-dev / prod.
GST line detection: lines whose description contains "gst" or "tax"
get summed into amount_tax_minor; everything else into amount_net_minor;
sum stays gross. Phase 1 enough — proper tax handling lands when
real per-tenant invoices flow.
cloud_invoices gains pushed_to_finance_at + finance_invoice_id so we
don't re-push uselessly (Billing.mark_invoice_pushed/2 records both).
A missing finance config (no :skyai_finance app env) makes the push a
silent skip rather than a worker failure — environments without finance
configured still get a working ingest.
Live verified end-to-end against both services:
- April 2026 DO invoice (33 lines, $86.92) lands in finance as a row
with gross=$86.92, tax=$7.90, source=digitalocean, tenant=platform-admin
- DigitalOcean vendor auto-created (category=cloud) under platform-admin
- Re-running the worker returns action: "updated" not "created";
finance still has exactly 1 row for the invoice
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new schemas:
- cloud_balance_snapshots — hourly MTD balance/usage poll for live-accrual.
- cloud_invoices — header per provider invoice, with ingest status flags.
- cloud_cost_lines — per-line-item COGS, FK to cloud_resources where matched.
Three new Oban workers (queue: cloud_billing):
- BalanceWorker (hourly) records a snapshot.
- BillingHistoryWorker (daily) discovers invoices via /v2/customers/my/
billing_history, upserts headers, enqueues an InvoiceIngestWorker for
each not-yet-ingested invoice.
- InvoiceIngestWorker (per-invoice) fetches /invoices/:uuid/csv, parses
with NimbleCSV (header-keyed so column order shifts don't break us),
replaces the invoice's line set, then matches lines to cloud_resources
by (kind, name) — case-insensitive, name extracted from "name (size)"
description format.
DigitalOcean.Client gains get_balance / list_billing_history /
get_invoice_summary / fetch_invoice_csv. The CSV endpoint returns text/csv
so we bypass Req's body decoder.
Cron additions: BalanceWorker hourly at :07, BillingHistoryWorker daily
at 02:23.
API:
- GET /api/v1/billing/balance — latest snapshot, platform_admin only.
- GET /api/v1/billing/cost-lines?period=YYYY-MM-DD&kind&limit — per-line
COGS, platform_admin only.
Live smoke against real DO billing API surfaced and fixed three CSV-format
gotchas: column headers use underscores not spaces (group_description,
project_name), USD column has $ prefix, dates use "YYYY-MM-DD HH:MM:SS
+0000" format (space separator + RFC822 offset).
Verified: 137 historical invoices discovered going back to 2014;
April 2026 invoice (33 lines, $86.92 total) ingested with 6/33 lines
matched to current cloud_resources. Unmatched lines are correctly
historic droplets, Spaces buckets (not yet synced), and GST.
NimbleCSV ~> 1.2 added as a dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Models:
- cloud_projects: arcadia-cloud's mirror of DO Projects, indexed by
(provider, provider_id); tenant_id + purpose classify each project.
- cloud_resources: single unified resource table; kind-specific bits in
attrs JSONB; first_seen_at / last_seen_at / stale_strike_count drive
three-strike deletion.
- cloud_resource_events: append-only audit (discovered, updated, deleted,
drift_detected, tagged, restored).
ArcadiaCloud.Cloud context owns the single upsert chokepoint that:
- inserts new with `discovered` event
- updates existing only when meaningful fields change
- restores tombstoned rows seen again
- bumps last_seen_at and resets strike count
mark_stale/3 implements the three-strike rule.
ArcadiaCloud.DigitalOcean.Client is a Req wrapper with auto-pagination.
Per-purpose token resolution via .Tokens (phase 1: env DO_API_TOKEN;
phase 2: vault). Per project_arcadia_cloud memory the long-term shape
is one PAT per queue purpose for rate-limit isolation.
ArcadiaCloud.Sync.Bootstrap ensures the skyai-internal DO Project exists
on first sync, idempotent thereafter. ArcadiaCloud.Sync.DropletsWorker
runs full droplet sync on the cloud_sync_full Oban queue.
InventoryController wired to real data: platform_admin sees all,
tenants see only their scope.
Live smoke test against real DO: 5 droplets synced; skyai-internal
project auto-created; events written; endpoint returns scoped results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API-only Phoenix 1.8 project for cloud-ops, inventory, billing, and
provisioning sagas. Validates arcadia JWTs via shared Guardian secret
(verify-only; arcadia-app remains the issuer).
Deps beyond default Phoenix: guardian, cors_plug, oban, req.
Postgres on local port 5433 per arcadia stack convention.
Endpoint runs on :4005.
Endpoints:
- GET /api/health — public, returns service identifier
- GET /api/v1/inventory — auth-gated, returns empty list (phase 0 stub)
Oban configured with the queues phase 1+ will need:
provisioning / cloud_sync_fast|full|slow / cloud_billing / metering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>