Sift Sift
Breathe first. Scroll later.

Dashboard

What happened each day — and whether it is trending up or down.

→
A tile with no vs prior pill has nothing to compare against — the earlier window totalled zero, so the change is undefined, not 0%. On Blended CPA and Blended TTR the pill is a relative change, not a change in points, and a — means the window had no installs (or no impressions) to divide by.

Subscriptions · flows over 28 days, balances as of now

RevenueCat-parity tiles. Each carries how its number was derived — a tile marked estimate is measured off a proxy, and its own caption says which way it reads against RevenueCat. A tile reading not connected has no data at all; that is not the same as zero. The first three count what happened over a fixed 28-day window; the last three are balances as of now, so their sparkline ends at the window's last complete day while the headline is current. Independent of the Range control above.

Experiments

Your ASA lab notebook — every theory, the change that tests it, and the verdict.

This is the layer Apple's console and a plain dashboard don't have: a hypothesis ledger that survives the ~24h reporting lag and a 7-day runway, and turns each finding into a reusable rule.

🌱 Learnings & rules adopted

The durable knowledge experiments leave behind — these constrain every future recommendation.

    Campaigns

    One row per campaign. Click a header to sort.

    →
    TOTALS is the account total, not the sum of the rows above — Apple masks low-volume terms, so per-campaign rows can under-count. — means we hold no data for that cell; 0 means we hold data and it is zero. Columns up to Spend are Apple; Tracked onward are RevenueCat, and Tracked is never the same number as Inst. Serving is Apple's answer to “is this actually running?”, not the status you set — the two disagree routinely, and a badge with no reason chips means Apple gave us no reason.

    Purchases

    Every RevenueCat purchase, newest first, with the campaign each is attributed to.

    →
    unattributed — resolving is the honest label while AdServices has not answered yet — it self-fills once the install resolves (or stays, for an organic payer). A sandbox row is a test purchase: visible here so testing is verifiable end-to-end, but never counted as revenue. Dates are inclusive UTC days. Purchases arrive live, so a sale landing while you page can shift a row across a page boundary.

    Keywords

    Last 7 days · band by TTR · click a header to sort.

    →
    🟢 ≥4%   🟡 2–4%   🔴 <2% TTR — banded on the unrounded rate, so a value shown as 4.00% can sit in the amber band. A keyword with no impressions has no rate to band, so it carries no dot; the ○ No impressions chip filters to those rows. Rows under ~100 impressions are directional only. CPA shown where installs > 0. Match, Status and the band chips compose as an AND, and the chip counts are the rows inside the two selects — so a chip's number is what clicking it shows. Status opens on Active + Paused: keywords Apple has deleted are still reported here, and they are one selection away.

    Search Terms

    What people actually typed — the source of negative-keyword discovery.

    Reports

    Pick a dimension, a window and a timezone; filter it; sort any column. Save it and it comes back.

    →
    TOTALS is the account total, not the sum of the rows above — Apple masks low-volume terms, so per-entity rows can under-count. — means we hold no data for that cell; 0 means we hold data and it is zero. TTR, CR, CPA and CPT are re-derived from the summed counts, never averaged from the daily rows.

    All reports

    A saved report stores the query, never the numbers — opening one re-runs it against today's data.

    Timeline

    Every account change on one axis — so you can read results against the change that caused them.

    Blended TTR, with change marker

    Change log

      AI Usage

      Every call we made to a model, what it cost, and why.

      Cost is computed at the moment of the call and stored — never re-derived from today's price list, so a price change cannot rewrite what you already paid. Shown to six decimals because a single call routinely costs less than a cent.

      Data & Sync

      Where this account's data came from, and when.

      Sync history

      Every run this account has made — scheduled and manual alike.

      One tick of the schedule writes three rows — sync, experiment eval, alert — so “did we pull fresh data?” and “did we raise anything?” stay separately answerable. Rows is what that job wrote: metrics for a sync, read-outs for an experiment eval, alerts for an alert. A fourth job, eval, ran between the sync and the experiment eval until the suggestion queue was removed; its runs are still listed, because Sift deletes nothing.

      The pipeline

      ① Apple Ads API v5
      Read-only pull: campaigns, ad groups, keywords, search-term reports.
      →
      ② MySQL
      Per-day metrics, reconciled against Apple's own grand totals before they land.
      →
      ③ Read out & alert
      Due experiments get their read-out, then deterministic rules raise alerts. No model is called in this phase.
      →
      ④ This view
      Dashboard, experiment ledger, scorecard.

      Webhook deliveries

      Every inbound RevenueCat delivery, newest first — and what Sift did with it.

      Admin-only. A rejected (401) delivery is logged with no body — so a mis-set REVENUECAT_WEBHOOK_SECRET shows up here, not only in the server logs. The Payload of an accepted delivery is the body as received, secret-scrubbed. Nothing is ever deleted from this log — there is no retention window and no nightly sweep.

      Live payload

      What the shipped app is told at launch — changeable without a release.

      Exactly what GET /api/v1/app-config returns for this account right now, from the same code path — so it cannot disagree with what the app receives. A disabled key is absent, never null: absence means the binary uses its compiled default, and {} is a complete, correct answer for an account with nothing turned on.

      Loading…

      Config keys

      Every key on the account, and what each one is serving — enabled or not.

      Every key on the account, enabled or not — a staged-off key is the thing you came here to turn on, so hiding it would hide the workflow. Enabled is the staging mechanism: there is no draft/publish step, so turning a key on is what puts it in the Live payload on every client's next launch.

      Deleting a key does not restore a previous value — it reverts every client to its compiled default on the next launch, the same blast radius as a value change and easier to do by accident. Only the audit trail keeps the last value. Reordering rules is a config change too: first match wins, so moving one above another can invert the answer for a whole storefront.

      Add a rule

      A default plus an ordered list of overrides — first match wins. New rules are appended last and staged off. (The AND-only rule and the eight-condition cap are stated under the conditions themselves, so the editor on an existing rule says the same thing in the same words.)
      ⚠ Do not write the negative. country ≠ CA → false is not a rule, it is the key's value. Encoding both directions is how a rule set acquires two rules that disagree and a serving order nobody can reconstruct.

      Add a key

      The key and its type are immutable after creation — a key that changes type mid-flight is a decoding crash in a binary already on people's phones that cannot be patched. To change a type, add a new key and retire the old one. Type the value the way JSON carries it: a string as-is with no quotes, an int or float as a number, a json value as JSON.

      Audit trail

      Who changed this account's configuration, when, and what it used to be.

      Newest first. Keys and rules both — a rule is named key#priority, and on a rule that moved, the priority shown is the one it left.

      Nothing can edit or delete a row here — the database refuses UPDATE and DELETE on this table for every user, including root. This is where a deleted key's last value survives.
      ⚠ Two things this does not do. Deleting the account destroys the whole trail: that foreign key cascades, and a cascading delete does not fire the triggers that make this table append-only. And it is not a safety net for a secret pasted into a value — that value is already on the wire to every device and already visible in the Keys table, so hiding it here would only remove the place you could discover it happened.
      ⚠ A change with no name against it is ambiguous, not anonymous. The column that records the actor is cleared when a user is deleted, and it is also how an automated change is recorded — so those rows read “system, or a user who has since been deleted”. There is no field that tells the two apart.

      Config deliveries

      Every launch that asked this account for its config, newest first.

      A 304 is a successful delivery — a warm cache revalidating — and it means that device is actively running that version. Click a version to see the exact document it carried.

      Every delivery is recorded — no sampling, and nothing is ever deleted. So an empty result means no such request was served, provided the delivery log has been on and healthy for the window you are looking at. A version with no payload to expand was still served: the account's snapshot budget was exhausted, or that write failed.

      Alerts

      What the pipeline noticed — and what has already been acknowledged.

      Acknowledging is one-way. There is no way to un-acknowledge an alert and no manual resolve. Only the rule-driven types — CPA spike, budget capped, keyword drag, TTR drop, spend anomaly — are ever auto-resolved, and only while they are still open, so sync failure, sync stale and experiment due stay acknowledged for good: the Acknowledged list only grows. That is the design, not a sweep that has stopped running.
      Opening this screen is what raises — and what clears — the pipeline heartbeat. sync stale is the one alert no background job can raise, because the failure it reports is the job not running, so it is evaluated when this screen reads the inbox. The check runs both ways: while the data is stale a row is raised, and the moment the pipeline is healthy again every still-open sync stale row is moved to resolved — by the read itself, on the spot. So a sync-stale row that appears the moment you look, or that reads resolved with a timestamp from a second ago, is the check working rather than a coincidence. It is the one status change here that nobody clicked, and All statuses is the view it is visible in. Acknowledging needs the operator role or higher; a viewer sees the inbox without the button.
      `, puts the view back on one side of the equality and fails the suite with a section nothing can reach. It did exactly that twice while this change was being written. It was READ-ONLY for its whole life, and that is why it went. No approve/reject route was ever built — `SuggestionStatus` modelled `approved`/`rejected`/`applied`/`superseded` and nothing in the system ever transitioned a row into any of them — so the one act the queue existed to make possible could not be made from it. (The enum itself is gone as of this release; it is named here in the past tense because it no longer exists to be looked up.) A screen whose entire purpose is deferred to a phase that has not started is a screen that teaches the operator a control exists. ⚠ NOTHING BEHIND THIS SEAM STILL ANSWERS, and that is the difference between #422 and the #286 attempt it supersedes. #286 removed the screen and left a live, uncalled API underneath it; #422 takes the whole vertical at once — the two routes, `SuggestionList`/`SuggestionOut`, `AlertOut.relatedSuggestionId`, the APNs `route="suggestion"` deep-link, and `EvalJob`, the sole writer. There is no dormant API here to hang a screen back onto: restoring this section is a rebuild across four layers, not a revert. The one thing that outlives the code is the `suggestion` TABLE, and only by one release. Rollback on this box is `docker compose up -d` on the previous image with **no `alembic downgrade`** (deploy/README.md:286) — expand/contract, so the schema must stay readable by the image being rolled back TO, and that image's `EvalJob` opens with an `UPDATE` on `suggestion`. Renaming the table in this release would turn every rollback into a chain that dies at step 2 and never reaches ALERTS. The table moves a release later, once no deployable image touches it. Recorded as SPEC-DEVIATIONS D38. -->

      Settings

      Your account and password, the provider keys Sift holds, and the API reference for Claude Code.

      Credentials

      Provider API keys for the vault. The secret travels inbound only — it is verified against the provider before it is stored, and what comes back is a masked fingerprint, never the key itself. Submitting for a provider that already has a key rotates it.

      Deleting destroys Sift's copy of the key; the row is kept for audit and can be replaced by adding the provider again. It does not revoke the key at the provider — do that in their console too.

      Change password

      Changing your password signs out every other device immediately. You will stay signed in here.

      At least 12 characters, and not one that appears in a breach list.

      API reference for Claude Code

      A single Markdown file describing every Sift endpoint and the conventions a client needs — auth, money units, the report filter grammar, rate limits, and what it can and cannot do. Hand it to the Claude Code CLI so it can drive Sift over HTTP instead of guessing.

      New experiment

      A theory is only real once it's written down with the change that tests it.

      Change a value

      Type the value the way JSON carries it: a string as-is with no quotes, an int or float as a number, a json value as JSON. The line above quotes a string value so you can tell "5" from 5 — the field never does.