Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.
A dedicated process that reads an organization’s usage and profile, decides whether an expansion offer is warranted, and spends an existing campaign budget to make it.
PromotionContext — the entire vocabulary a
promotion can match on — is { tier, interval, planPriceCents,
isFirstSubscription }. Four static billing facts, and not one of them says
anything about what the organization actually does with the product.$899/mo giveaway.PlanChangeModal shows no discount at all, so a 50%-off Business offer opens
the upgrade modal at $249. That is a prerequisite, not polish.Every other finding lives in the decision that owns it; this section was eight bullets longer before five editing passes and stopped being highlights.
The pricing model sells expansion three ways: move up a tier, buy a capacity pack, or buy a feature add-on. Nothing in the product notices when an organization has grown into one of those and says so. The quota service already tells an org when it is near a limit; it has no idea that the limit has a price attached.
This plan puts a process between “what the organization is doing” and “what the catalogue sells”, and lets it spend a bounded campaign budget to close the gap.
Nineteen decisions and twenty-one open items is the shape of a plan that was reviewed five times, not the shape of a first increment. Four things ship without Phases 0–6, without an agent, and without a campaign budget:
PlanChangeModal (F1). A prerequisite: without it a
50%-off offer opens the upgrade modal at list price.Items 2, 3 and 4 are information, not solicitation — which is why they need no consent, no quiet period and no holdout, and why they are worth building even if the rest of this plan is never started. Item 1 is a bug.
Everything after this point is the agent, and its first irreversible commitment is the append-only profile (Phase 0) — the one decision here that cannot be revisited later.
Four pieces of machinery already exist, and together they are most of the work.
1. The discount codec and record (api/billing/src/models/discount.ts). A
mintable discount carries value/unit/kind, an optional campaign, an
alias for public promos, a targetOrgId binding, maxRedemptions,
timesRedeemed, redeemBy, appliesToTiers, appliesToBundles and an
externalRef for provider fulfillment. The record is the authority for
redeemability, and revoking is flipping isActive, which kills every token
minted for that id at once.
2. Campaigns (models/campaign.ts), which hold what cannot be expressed per
code: budgetCents / spentCents, grantsCount, exclusive, a
startsAt/endsAt window and isActive.
3. Promotions and an evaluator (models/promotion.ts,
helpers/promotion-engine.ts). A promotion has a trigger, conditions, a
budgetCents, a perOrgCapCents, maxGrants, and one-time versus recurring
kinds. batchEvaluatePromotion walks activeSubscriptionCursor(), builds a
context per subscription, checks matchesConditions, and calls
grantPromotionToOrg — with budget exhaustion, already-granted and fail-soft
error paths all handled.
4. A scheduled runner (helpers/promotion-backfill.ts) using
createScheduler with leaderLockKey('billing', 'promotion-backfill'), which
reconciles advisory spend from the ledger before catching up missed grants.
And the guard rails are already there, which matters more than the features.
config.ts caps a percent coupon at mint time (default 50, deliberately not
100, because “at 100 a mistyped coupon is a $899/mo giveaway”), caps a fixed
amount at $20,000, and separately bounds the whole stack of
interval + volume + combo + coupon reductions. An automated granter is exactly
the actor those ceilings were written for.
PromotionContext is the whole targeting vocabulary, and it is static.
{ tier, interval, planPriceCents, isFirstSubscription }
PromotionConditions matches on tiers, intervals and
firstSubscriptionOnly. PromotionEvent is one of subscription_created,
plan_change, manual, referral — every one a billing lifecycle event.
So a promotion can say “20% off annual for Pro accounts on their first subscription”. It cannot say “this account has spent three consecutive periods above 90% of its plugin-build cap”, which is the only kind of statement an expansion offer should rest on.
There is no targeting or segmentation logic anywhere in the repository. No propensity model, no segment definitions, no usage-derived eligibility.
There is no marketing-consent model. Nothing in any service’s src tracks
whether an organization has agreed to be sold to, or has asked not to be.
Decision: the service is api/offers, the thing it produces is an Offer, and
the word “agent” does not appear in the product surface.
“Sales agent” describes who benefits, not what the thing does, and it sets an expectation the plan does not meet: a conversational salesperson that negotiates. This process does something narrower and more defensible — it notices that an account has grown into something the catalogue sells, and makes a bounded, auditable, expiring offer.
The name also has to survive the thing getting good. If an Offer record is the
unit, then “why did this account get 20% off?” is answerable by reading one row,
and the same record serves the admin surface, the audit trail and the lift
measurement. A record called a “sales agent decision” answers nothing.
offers collides with nothing. The billing routes are addons,
campaigns, discounts, promotions, marketplace, subscriptions, usage,
billing-summary, read-plans, support-incidents, admin-subscriptions and
stripe-webhook.
Decision: the agent computes SIGNALS and widens PromotionContext /
PromotionConditions to match on them. It does not grant money itself.
This is the plan’s most important structural choice, and the reasoning is that
everything dangerous is already solved on the existing path: grant dedupe via
dedupeKey, perOrgCapCents, budget exhaustion as a first-class outcome,
advisory-spend reconciliation against the ledger, audit on mint, the mint-time
ceilings, and the stack bound.
A parallel granter would have to re-earn all of it, and the failure mode of getting it wrong is handing out money. So:
Offer and, where the offer is a discount, calls the
SAME grantPromotionToOrg path the admin surface and the backfill use;usage block on PromotionConditions carries the predicates;PromotionContext gains the computed signals, so matchesConditions stays the
single place eligibility is decided.One consequence to accept. batchEvaluatePromotion currently builds its
context from a plan lookup per subscription and an optional countDocuments.
Usage signals cannot be computed inside that loop at acceptable cost, which is
why Phase 0 builds a signal store the loop READS rather than a computation the
loop performs.
Decision: api/offers ships as its own service and image, as specified. The
cheaper alternative is recorded here because the choice should be visible.
What a tenth service costs in this repository. There are nine today
(ask, billing, compliance, image-registry, message, pipeline,
plugin, quota, reporting). Adding one means manifests in three Kubernetes
trees (40 / 44 / 47 files today) plus docker compose, a NetworkPolicy, an Istio
entry, a Prometheus scrape target, its own Redis/Mongo wiring, and the
deploy-contract drift tests that keep the trees in lockstep. That is real work
and it is work this plan does not otherwise need.
The cheaper alternative, for the record: a scheduler inside api/billing,
exactly like promotion-backfill. It would need no manifests, no policy and no
new scrape target, and the existing scheduler + leader-lock pattern is proven.
What justifies the separate image anyway, and it is worth being specific rather than appealing to tidiness:
And D13 reduces this bill. A service with no public routes needs no Istio ingress entry, no auth middleware and no route-table contract — the expensive half of a new service here is its public surface, and this one has none.
But sequence the extraction, do not lead with it. Phases 0–3 are testable as a scheduler inside billing, and shipping them that way proves the signals and the rules before paying the deployment cost. Phase 5 extracts. If the rules turn out to be wrong, that is a far cheaper place to find out.
Decision: the profile carries PRESSURE, TRAJECTORY, VALUE, PROFILE, TIMING and SEQUENCE.
An earlier draft of this decision listed only pressure signals. Pressure answers can we sell them more of what they have — and not one of the questions that follow it: should we, which SKU, when, and how much. An account at 95% of a cap that is quietly disengaging is a churn save, not an expansion, and offering it an upgrade is the worst available outcome. A pressure-only profile cannot tell those two accounts apart.
Every source here already exists; nothing needs instrumenting.
| Signal | Source today |
|---|---|
| Per-type usage and limit, per period | the quota service’s counters, surfaced by /usage and the quota summary |
| Threshold crossings (80 / 90 / 100%) | crossedThresholds in api/quota/src/services/threshold-notifier.ts |
| Registry bytes, and each tag’s marginal cost | computeOrgStorageBreakdown in image-registry |
| Add-ons held against the tier’s own caps | the subscription record plus DEFAULT_TIER_LIMITS |
| Seats filled against the seat cap | the org membership count, enforced at invite time |
The headline is the one nobody has to build. threshold-notifier already
computes a 90% crossing, writes the notice copy and emits quota.threshold. That
crossing IS the expansion moment — the account has just been told it is running
out of something with a price attached — and the notice does not mention that a
pack exists.
“At 90%” is a weaker signal than “was at 40% three months ago, now at 85%”. Velocity says when the wall arrives, which is what lets an offer land before it rather than at it. Pressure alone can only ever offer late.
The formula already exists, in the wrong place. QuotaCard.tsx projects
period-end usage inline:
projected = used * (total / elapsed)
That is React component arithmetic. There is no server-side burn rate anywhere, so nothing but the card can use it. Extract it to api-core beside the quota catalog and three consumers share one projection: the card, the threshold notice, and this agent. Doing it the other way — reimplementing the projection here — guarantees the page and the offer eventually disagree about the same account.
Derived from it, and this is where the judgement lives:
This is the class with no data behind it, and the gap is load-bearing. There
is no lastLoginAt, lastSeenAt or lastActiveAt anywhere in the repository, so
“are those five seats actually used?” cannot be answered today.
That question is the key disqualifier for the strongest offer in the plan. An org holding 5 seats with 2 active people is already paying for shelfware; selling it 8 seats manufactures a cancellation at renewal. Without seat activity, a seat-driven tier offer is being made blind.
Cheapest honest form: a per-member last-activity stamp written on the authenticated-request path the audit layer already traverses. Coarse is fine — day granularity answers the question. Open item 10.
Two more value signals that DO have sources: build success rate (a high failure rate means the product is not working, so do not sell more of it) and whether an add-on the account already holds has been exercised at all.
Available today, and entirely unused by the plan’s earlier draft.
| Signal | What it indicates | Source |
|---|---|---|
idpConfigs configured |
enterprise-shaped; the caps are 5 / 5 / 10 / unlimited, so an org adding IdPs is climbing | quota limits + the IdP records |
childOrgCount > 0 |
they have built teams, so structurally a larger organization | already computed for /user/organizations |
| Compliance add-ons held | regulated, which changes the objection set entirely | subscription add-ons |
listings used |
a marketplace publisher, whose motivation is reach rather than capacity | quota counters |
| Tenure, and tier history | how settled they are, and whether they have moved before | Subscription, plan_change events |
The subscription carries currentPeriodEnd. Two consequences the earlier draft
ignored completely:
The other five classes describe the account. This one describes the conversation so far, and an earlier draft left it scattered across two decisions instead of naming it as an input.
It matters because a decline is the most informative thing an account ever tells the agent: it said no to something specific. Choosing in isolation each cycle discards that, and a sequence of independently-chosen offers reads as a machine retrying rather than a conversation narrowing.
What the class carries, each owned by a decision elsewhere:
| Signal | Owned by |
|---|---|
| What was offered, and what the account did with it | D12’s outcome log |
| Which instrument was declined, so the next is the next-best rather than a re-roll | D6 |
| Whether the last resolution was an acceptance, which lengthens the window for its own reason | D6 |
| Slots deliberately HELD, with the reason and the forecast date | D17 |
The holds belong here more than anywhere else. A held slot is a statement about the future the agent made on a previous cycle, and the next cycle has to be able to read it — otherwise “hold for the crossover in two months” becomes an unbounded series of fresh decisions to hold, which is the failure mode D17 caps.
How much is this account spending on packs, against the next tier’s price?
Pure arithmetic from the subscription record — no inference, no model, no threshold to tune. And it produces the only offer in this plan that needs no discount at all: no renewal cliff (open item 9), no stack-floor risk (D9), no campaign budget spent.
Worked: Team includes 3 seats at $79. Four Member Seat packs at $49.99 reach $278.96 for 7 seats — against Business at $249 for 8 seats, with double the storage, pipelines and plugins and unlimited dashboards and alert rules. That account can simply be shown it is paying more for less.
Ship this before anything that requires a discount.
Counting pressured dimensions is not enough; WHICH dimensions are pressured together names the SKU. This map is the part of the plan that makes it smart, and an earlier draft left it implicit:
| Pressured together | What the account is | SKU to offer |
|---|---|---|
plugins + cpuCredits |
a heavy build shop | pipeline_pack, or the tier |
apiCalls + automationCalls |
automating against the platform | automation_pack |
storageBytes + plugins |
accumulating images | storage_pack — or GC advice, which is a reason not to sell |
aiCalls alone |
leaning on generation | ai_pack |
seats + anything else |
outgrown the tier, not a dimension | the tier |
Without the map the agent can only offer a pack for the single most-pressured dimension, which is the naive answer and often the wrong one — and in the storage case the right answer may be to tell them to delete tags rather than sell them disk.
One dimension’s pressure never implies a tier. A tier offer requires breadth (several dimensions under pressure, or seats), because a single pack relieves a single dimension far more cheaply and an account that notices will say so.
Decision: eligibility and amount are DETERMINISTIC. The model may supply SIGNALS the rules cannot compute, and may write the message. It may not create an offer, choose between offers, set an amount, or widen eligibility.
This is the boundary that makes the feature safe to ship, and the reason is not
abstract caution: the existing coupon ceilings were written because one mistyped
percent could be an $899/mo giveaway. A model that could set the number is that
mistake with an API.
So the split is:
An earlier draft of this decision let the model RANK permitted offers, and Phase 4 now explains why that was the weakest possible use of it. With D4’s co-occurrence map, D17’s option value and D18’s EV calculation, choosing among two or three permitted offers is arithmetic. A model there contributes variance in which offer an account receives, which is the one kind of inconsistency a customer can catch you at.
Everything the model is allowed to influence is already bounded by something
else. It cannot exceed the campaign budget, the perOrgCapCents, the mint-time
ceiling or the stack bound, because it never touches the grant path — it picks a
row the rules produced.
And it must be optional. The feature has to work with the AI path disabled,
because aiCalls is itself a metered quota and because an AI outage must not
stop expansion offers. With the model off, the model-supplied signals are simply
absent from the profile and the rules decide on the rest — which is a weaker
decision, not a broken one, and is exactly how a missing signal should behave.
Decision: offers are opt-OUT with a real unsubscribe, capped in frequency per account, and suppressed during a set of named states.
Nothing in any service’s src tracks marketing consent today, so this is new —
but the pattern to copy is already in the repository.
api/reporting/src/routes/public-reports.ts serves
POST /public/report-recipients/unsubscribe with a token, and documents why it
is a POST: “security scanners and corporate link-prefetchers follow GETs”, so an
unsubscribe on GET gets triggered by a scanner and the recipient never learns why
the mail stopped. RFC 8058 one-click is a POST for the same reason. Copy that
shape exactly rather than inventing a second one.
offersEnabled, default TRUEDecision: an account-root boolean defaulting to true, on the settings object
that already carries the quota-notice switch, gated on billing:manage.
The precedent is exact, so this is a copy rather than a design.
QuotaNoticeSettings already carries enabled, webhookUrl, thresholds[] and
an instance-level noticesEnabledOnInstance, scoped to “the account root the
settings belong to”, rendered by QuotaNoticeSettingsCard and served from
api/quota/src/routes/usage-routes.ts. offersEnabled sits beside it.
Four properties, each chosen rather than inherited:
billing:manage, for the same reason. It is not a notification
preference every member may flip.noticesEnabledOnInstance — and it
defaults OFF where billing is disabled, because a deployment with nothing to
sell should not be making offers. A self-hosted operator turning the whole
feature off must not have to visit every account.And its interaction with the quota-notice switch has to be decided, not
inherited. offersEnabled sits on the settings object that already carries
enabled for quota notices — and Phase 3 delivers offers over
threshold-notifier’s path. So an account with notices OFF and offers ON is an
unresolved state with two bad readings: the offer never arrives (the switch
silently means something it does not say), or it arrives through a channel the
account explicitly muted.
Resolve it as: notices off suppresses offers on that channel, and the two
switches are AND-ed for delivery rather than offers overriding. An account that
muted the quota channel muted the quota channel; an offer is not an exception it
asked for. The in-product surface (F4) is a different channel and is governed by
offersEnabled alone — which is also the argument for F4 existing rather than
leaning entirely on email.
The per-address unsubscribe is a DIFFERENT granularity, and conflating them is
a bug. api/reporting’s unsubscribe stops mail to one address. One admin
unsubscribing themselves must not silently disable offers for the organization,
and the org switch must not resurrect mail to an address that asked to stop. Two
records, both consulted.
Decision: the quiet period runs 45 days from when the offer is SENT, and the offer’s own expiry window counts INSIDE it. A 30-day offer is followed by 15 days of nothing, and the next offer can be made on day 45.
So the invariant is a RATE, not a gap: at most one offer per account per 45 days. That is the anti-flooding guarantee, and it is the one an account can actually perceive — what they experience is how often something arrives, not how long the silence between arrivals was.
An earlier draft measured from RESOLUTION instead, and that was wrong for a reason worth recording, because the mistake is easy to repeat. Resolution means redeemed, declined, or expired — so a declined offer resolves on day 2 and starts the clock immediately, while an ignored offer resolves on day 30 when it lapses:
| Resolves | Next offer, from resolution | Next offer, from send | |
|---|---|---|---|
| Declined early | day 2 | day 47 | day 45 |
| Ignored, 30-day expiry | day 30 | day 75 | day 45 |
From resolution, declining earns a faster next offer than ignoring — which contradicts this decision’s own rule that a decline lengthens the window, and rewards the account that engaged with the one thing it asked not to receive. Measuring from send makes both 45, and the decline backoff then applies on top, in the direction it was meant to.
It also makes the cadence independent of the expiry length. From resolution, a longer expiry silently lengthened the whole cycle; from send, expiry is a decision about how long the offer stands and nothing else.
One constraint this creates: the expiry must be shorter than the quiet period, or offers overlap and the rate is undefined. 30 days against 45 leaves a sensible margin; an expiry at or past 45 is a configuration error and should be refused rather than clamped.
Three more properties:
No new store is needed. The Offer record from Phase 2 carries its sent-at
timestamp, so “the most recent offer, and was it within 45 days” is a query rather
than a counter to maintain — and measuring from send means the answer does not
depend on the row’s status, which is one less way for it to be wrong.
And the quiet period governs SOLICITATIONS, not DISCLOSURES. F2 (what a limit costs, on the quota card) and F3 (an account’s real spend against the next tier) are always-visible information, not offers. Suppressing them during a quiet period would make the product worse to protect the account from being sold to — the opposite of the intent. Nothing in the quiet period may hide a price the account is entitled to see.
Suppress entirely while any of these hold, because an offer landing in one of them does active damage:
trialing subscription — not “an account inside its first N days”, which
an earlier draft guessed at. MANAGEABLE_SUBSCRIPTION_STATUSES already carries
trialing as a real status, so suppress on the thing the system models rather
than on a day count that will drift from it;past_due subscription — and this is deliberately STRICTER than the
platform. past_due is in MANAGEABLE_SUBSCRIPTION_STATUSES, so the system
will let such an account change plan and redeem discounts. It should still not
be solicited: asking for more money from an account whose last payment failed
is the clearest own-goal available. Recorded as a policy choice rather than
left looking like an inconsistency;plan_change
events already detect it;Decision: a do-not-offer list, and a manual trigger.
The veto is the price of shipping this at all. Somebody will know something the signals cannot: an account in contract negotiation, a strategic account whose pricing is being handled by a person, an account being actively saved. Without a way to say “not this one”, the first awkward offer becomes an argument for switching the whole feature off — and that argument wins, because it is correct.
The trigger is nearly free. PROMOTION_EVENTS already includes 'manual',
so “offer this account now” has a hook; what it needs is a reason recorded
beside it, so a manual offer is distinguishable from an agent one in D12’s
outcome log. Mixing them silently would corrupt the only measurement that
matters.
The suppression counts are a product metric, not plumbing. A feature suppressing almost everything is misconfigured rather than careful, and that is only visible if every reason is counted separately (Phase 6).
Decision: a deterministic 10% holdout by a hash of the root org id, never offered to, from the first cycle.
Expansion revenue is the easiest thing in a product to claim and the hardest to prove. An account that was going to upgrade this month will upgrade this month, and if it received an offer first, the offer gets the credit. Without a control group there is no number anybody should believe.
Deterministic by id hash rather than random per cycle, so an account’s holdout membership is stable — a account that flips in and out of the holdout is in neither group.
And it has to exist from the beginning. A holdout added after launch cannot measure the launch, which is the measurement that decides whether any of this was worth building.
Decision: any error in signal computation, rule evaluation or consent lookup results in NO offer.
batchEvaluatePromotion is deliberately fail-soft: a grant that throws is logged
and the loop continues, which is right for an admin-triggered campaign where the
alternative is one bad row stopping a run.
The inverse is right here. The cost of not making an offer is a deferred sale;
the cost of making a wrong one is money, or a message to an account that asked
not to receive it. So an unreadable consent record, a stale UsageProfile or a
quota service that cannot answer all mean the same thing: skip this account this
cycle, count it, and move on.
This mirrors the quota service’s own QUOTA_FAIL_CLOSED reasoning — when the
answer cannot be confirmed and the action costs something, do not take it.
Decision: no offer is made until it has been priced through the existing preview path. An offer the system will not honour is worse than no offer.
config.ts carries a floorPercent (default 40): the account must retain at
least 40% of list, so the whole stack of reductions cannot exceed 60% off. The
comment explaining it names a scenario almost exactly:
“a scoped coupon on a bundle that already earns volume takes a second slice of the SAME base — Team with 5 Seats + Team Usage Analytics + the Team Growth combo + a scoped 50% code reaches 80% off list today. 40 is chosen so the catalog’s own maxima remain exactly reachable — a 30% combo plus a 30% volume curve is 60% off, landing on the floor — and a coupon on top of a maxed stack is what gets refused.”
So the account that looks most expansion-ready is often the one least able to accept a percentage. An org already holding a combo and earning a volume curve has spent its discount headroom; a 50% offer on top of it is refused at redemption — which is to say, after it was promised. That is the worst possible ordering, and it is invisible to any rule that reasons only about usage.
promotion-engine.ts already exports previewPromotion, used by the admin
routes. The agent calls it before writing an Offer, and a preview that comes
back refused or below a materiality threshold is a suppression reason (D6), not
an offer.
And the pre-flight has to price what the account LOSES, not only what it
gains. AddonPreviewModal already warns that a change “ends your {name}
discount”, so an upgrade or a compose basket can terminate a discount the account
is currently holding. An offer whose net effect is negative once that termination
is counted is worse than no offer, and only the preview path knows.
One more ceiling worth stating. maxPercent defaults to 50, so a 50%
offer sits exactly ON the mint-time ceiling with no headroom. Any offer deeper
than half price is not a pricing decision, it is a config change to the bound
that exists to stop a mistyped coupon becoming an $899/mo giveaway.
Decision: percentage discounts for TIER moves; nothing for capacity packs until a quantity instrument exists.
A percentage is the right instrument when the thing being discounted is expensive. It is close to useless when it is not, and the catalogue spans two orders of magnitude:
| Offer | List | 50% for a year is worth |
|---|---|---|
| Business tier | $249 / mo | $1,494 |
| CPU Credits Pack x 4 | $9.96 / mo | $59.76 |
And the pack case has a structural problem on top of the small number. The
discount model sizes a percent discount scoped with appliesToBundles against
the subtotal of those bundles for bundles the account HOLDS — and says
explicitly that “for unit: 'percent' with no matching held bundle the credit is
0 (nothing to take a percentage of)”. An acquisition offer — buy the pack you do
not have, half price — therefore computes to zero when expressed that way.
Expressing it at all means unit: 'dollar' with a hand-computed cents amount,
which works and which an automated agent is well suited to, but the amount is
then small enough to question whether it moves anybody.
What a capacity pack actually wants is free UNITS, and that is not expressible. Every discount grants cents of usage credit and “cannot grant a bundle the account does not hold”. Granting 2,000 CPU credits outright is an entitlement, not a discount, and no machinery here does that.
So the honest conclusion is that the tier offer is the one worth building
first, and the pack offer is either a dollar amount nobody will notice or a
new entitlement-granting instrument this plan does not scope. Recorded as open
item 8 rather than pretended away.
Decision: the deeper discount goes to the marginal account, not the desperate one.
D5 says the rules set the amount and gave no principle for it. The intuitive principle is backwards, and expensive:
A naive system does the opposite, because the most pressured account looks like the hottest lead. It is the hottest lead, which is exactly why it needs the least help.
Depth is also bounded before it is chosen, not after. maxPercent (default
50) caps the mint, floorPercent (default 40) caps the stack, and D9 prices the
offer through previewPromotion first — so the rule picks a depth within a
window the catalogue has already narrowed.
Decision: every offer’s outcome is recorded, and the rules are retuned from that record on a human cadence. Nothing adjusts itself.
An earlier draft measured against the holdout and then did nothing with the measurement — the plan had no path from “this did not work” to “this changed”.
What gets recorded is the whole decision, not just the result: which signals
fired, which SKU was offered, at what depth, from which campaign, and what the
account did. That record is already most of the Offer row from Phase 2; the
addition is the outcome and the horizon it is judged over.
And the outcome chain has to run past the purchase. Conversion is the beginning of the answer, not the end of it:
offered → accepted / declined / ignored → did the pressure actually resolve? → revenue → any later unsubscribe or support spike
“Did the pressure resolve” is the measure that catches selling the wrong relief. An account that buys a storage pack and is still at 95% a period later was sold something that did not fix its problem — which conversion scores as a win, D19’s cohorts would learn from as a success, and the account experiences as having been upsold ineffectively. It is also the only check on D4’s co-occurrence map being right rather than merely plausible.
And one outcome distinction matters more than the headline rate: did the account buy what was offered, or something else? Buying something different means the nudge worked and the targeting did not — the most actionable failure available, and indistinguishable from a win if only conversion is counted.
The retune should arrive COMPUTED, not intuited. Open items 4, 5, 6, 18, 19 and 21 — the sustained-pressure threshold, the depth per offer type, the decline backoff, the hold cap, the cost of annoying an account and the holdout size — are all numbers somebody picks once. Retuning them by reasoning about them again is how a guess becomes a tradition.
So the outcome log produces a PROPOSAL: “conversion stops improving below 30% depth”, “four periods discriminates better than three”, “the pack threshold is costing more slots than it earns”. A person approves it. That keeps the human gate this decision exists to defend while removing the part of it that was never really human judgement — reading a table.
Why not online learning. Three reasons, in order of weight: the thing being adjusted is money, so a bad adjustment is a refund conversation rather than a worse ranking; the volume will be far too low for a feedback loop to converge before the catalogue itself changes under it; and a rule a person tuned can be explained to the account, which an adjusted weight cannot. The model stays inside D5’s boundary — ranking and copy — and the rules stay inspectable.
Decision: api/offers exposes no public routes. It is a worker. Everything a
customer or an admin sees is served by billing’s existing routes reading the
Offer rows this service writes.
An earlier draft never said this, which left the most important property of the service implicit — and the one place implicitness is least affordable, because this process mints discounts.
An externally reachable endpoint that can mint a discount is the highest-value
target in the product. The mint-time ceilings in config.ts exist because a
single bad mint is an $899/mo giveaway; those ceilings are the second line of
defence, and the first is that nothing outside the cluster can reach the minter
at all.
/internal/* behind
requireServicePrincipal, the convention already in use across billing,
image-registry and message (permission-gates.ts:189, tagged
{ kind: 'servicePrincipal' }). In practice that is a cycle trigger and a
health endpoint, nothing more.The customer-facing offer surface and the admin view belong to BILLING, not to
this service. F4’s offer card, its decline verb and its server-side dismissal,
and F8’s admin tab, are all routes on api/billing reading and updating Offer
rows. That is the right home regardless of this decision — they sit beside
DiscountRedeem, PromotionDetailDrawer and BillingAdminTabs, which is where
a user already goes for anything commercial — and it means the frontend gains no
new API host.
D3 enumerated the cost of a tenth service as manifests across three Kubernetes trees plus docker, a NetworkPolicy, an Istio entry, a Prometheus scrape target and the drift tests. A route-less worker needs fewer of those: a Deployment, a NetworkPolicy with egress only, and a metrics scrape. No Istio ingress entry, no public auth middleware, no route-table contract, no frontend disposition map entry.
So internal-only strengthens the case for the separate image rather than weakening it: the expensive half of a new service is its public surface, and this one has none.
One honest caveat. A route-less service is a new shape in this
repository — all nine existing services expose routes — so the manifests have no
exact template to copy, and the Prometheus scrape needs whatever the
kubernetes-pods job keys on rather than a Service fronting an API. Cheaper than
a full service, but not free, and not a copy-paste.
Decision: the offers agent holds its own service identity, and every grant it
makes is attributed to that identity rather than to SYSTEM_ACTOR_ID.
DiscountDocument.createdBy is documented as “JWT sub of the system admin who
minted it (absent on seeded/system paths)”. An automated grant has no human
admin, so without a decision here it lands as absent or as the generic system
actor — and “why did this account get 50% off?” is the only question anybody
will ask about this feature.
An absent actor makes that unanswerable. The generic system actor makes it ambiguous: the audit shows system work, of which this deployment does a great deal, and nothing separates a discount the agent minted from a seed, a backfill or an operator script.
The repo already has the shape. System-org job Roles exist precisely to move
vendor work off a blanket superadmin flag onto named identities, and
requireServicePrincipal already distinguishes service callers from users. The
agent gets one of those identities, and three things follow:
createdBy names the agent, so the Offer row, the Discount row and the
audit entry all point at the same actor.Decision: a per-cycle grant ceiling and a per-cycle idempotency key, both separate from the campaign budget.
campaign.budgetCents bounds how much a campaign may ever spend. It says nothing
about how fast, and for a scheduler that grants money those are different
safeties:
And the cycle needs an idempotency key, which dedupeKey does not provide.
Grant dedupe protects the GRANT: the same promotion cannot be granted twice to
one account. It does not protect OFFER CREATION — a cycle that dies after writing
offers but before finishing can re-run and offer again to accounts it already
reached, each one a distinct offer and so each one passing dedupe.
Key it on (orgId, cycleId) and the re-run is a no-op for the accounts already
handled. D8 settles what happens when a cycle ERRORS; this settles what happens
when it is RE-ENTERED, which is a different failure and the more likely one.
Decision: eligibility is a FORECAST of the next period, compared against the account’s OWN baseline.
Two changes, both cheaper than they sound and both reading the history Phase 0 now keeps.
Forecast rather than level. “They are at 90%” is a statement about the past. “They will exceed their cap next period” is the one an offer should rest on, because it is the only form that lets the offer arrive BEFORE the wall rather than at it — and arriving at the wall is arriving after the account has already been inconvenienced, formed an opinion, and possibly worked around it.
The projection is already being extracted out of QuotaCard into api-core (D4
Class 2), so the arithmetic exists. Even a crude form beats a threshold: “three
of the last four periods trending up” is a better eligibility test than any
figure on the current one.
Self-relative rather than absolute. “This account’s build volume is three times its own six-month median” means something; “above 90%” means something different for every account. Accounts differ enormously in shape — a lumpy account that spikes to 95% every fourth period and a steady one climbing through 80% look identical to an absolute threshold and are completely different prospects.
The two compose. The signal worth acting on is a forecast breach against a rising own-baseline: this account is growing relative to itself, and that growth runs out of room next period. That is a sentence an account would recognise as true about itself, which is the bar the copy has to clear anyway.
Decision: the agent may DECLINE TO OFFER because a better offer is coming.
D6 allows one offer per account per 45 days. At six or so slots a year, the scarce resource is not the budget, it is the slot — and the plan as drafted picks the best offer available now, which is a different and worse question than the one that matters.
Worked, with the plan’s own figures. An account is exhausting CPU credits today; the pack offer is worth about $59 a year (D10) and is the weakest instrument in the catalogue. The same account’s seat trajectory says a tier crossover lands in two to three months, worth $650+ and needing no discount at all. Spending the slot today buys the small thing and forecloses the large one until day 45 at the earliest.
So the rule is comparative rather than absolute: offer when this is the best offer expected within the window, not merely when an offer is permitted. Holding is a legitimate outcome and must be recorded as one — “held: better offer forecast” is a suppression reason (D6) with a date attached, not an absence.
And it has a failure mode to bound. An agent that always expects something better never offers. Cap the hold: a slot may be held at most N consecutive cycles, after which the best currently-available offer is made. Perpetual anticipation is indistinguishable from a broken rule, and it is the version of this decision that looks clever and does nothing.
Decision: an offer is made when its expected value is positive, where the cost of annoying the account is a TERM rather than a gate.
EV = P(convert) x value − P(annoy) x cost_of_annoying
D6’s suppression list is a set of binary gates, which is right for the hard cases — an open incident, a failed payment, an explicit opt-out. It cannot express the marginal ones, and the marginal ones are most of them: an account at 82% with flat usage and two support tickets is neither clearly eligible nor clearly suppressed.
The second term is the one the plan never priced. Phase 6 measures unsubscribe rate; nothing uses it. Once annoyance has a cost, three things become possible that are not possible with a checklist:
Keep the hard gates as gates. Consent, an open incident and a failed payment are not terms to be outweighed by a large enough upside; they are refusals. The EV calculation decides among offers that have already cleared them.
Decision: compare an account to accounts that looked like it, and measure which SIGNALS earn their place rather than only whether the feature does.
Cohorts are the only thing in this plan that deserves the word propensity.
“Accounts that looked like this one ninety days ago converted at X” is a
statement the rules cannot make today and the history in Phase 0 makes
computable — with no model, no training and no new data. It is also what turns
D4’s co-occurrence map from a hand-written guess into something evidenced: if
plugins + cpuCredits accounts convert on pipeline_pack and not on the tier,
the map should say so because the data did, not because somebody reasoned it.
And the holdout proves the FEATURE, not the signals. D7 answers “does offering work”. It is silent on which of D4’s six classes is carrying the result, which means a signal that never discriminates survives indefinitely and every rule built on it inherits the noise.
D12 already records which signals fired on each offer, so the comparison is nearly free: the fired-signal distribution of converting offers against non-converting ones. A signal that appears equally in both is costing complexity and contributing nothing, and should be dropped rather than tuned.
This is also the discipline that keeps the signal set honest. The temptation with a plan like this is to keep adding signals, because each one sounds like intelligence. Signal COUNT is not intelligence; discrimination is, and this is how it gets measured.
Example A — Team at 5 seats, offered Business. The strongest signal in the catalogue, because it is arithmetic rather than inference.
Team includes 3 seats, so an org running 5 has bought two Member Seat packs at $49.99 (no volume break — that needs 5 units):
| Today | Business at 50% for a year | Business at list | |
|---|---|---|---|
| Plan | $79.00 | $249.00 | $249.00 |
| Seat packs | $99.98 (2 x $49.99) | — (8 seats included) | — |
| Discount | — | −$124.50 | — |
| Monthly | $178.98 | $124.50 | $249.00 |
The offer saves $54.48/month — about $654 over the year — while raising seats from 5 to 8, doubling storage and pipelines and plugins, and taking dashboards and alert rules to unlimited. It is a good offer and the agent can compute every figure in it.
But it has a cliff, and the cliff is the risk this example teaches. At
renewal the account goes to $249, which is $70.02/month more than it pays
today. An account that upgraded for the discount rather than for the capacity
will try to leave — and if it has grown into its 8 seats,
checkEntitlementOvercap will refuse the downgrade until it removes members. A
discount that converts an account into feeling trapped is worse than no
conversion.
The pack-versus-tier crossover is the version with no cliff. Team plus packs reaches Business’s $249 at four packs — seven seats — so an account at 6 or 7 seats can be shown that it is paying more for less, with no discount at all. That offer survives renewal because it was never subsidised.
Example B — Pro running out of CPU credits, offered the pack. Pro’s planned budget is 11,250 credits a month; an account repeatedly exhausting it needs roughly 4 packs to get comfortable, which is $9.96/month.
Half off for a year is $59.76 of value, and per D10 it cannot even be expressed as a scoped percentage until the account holds the pack. The signal here is excellent — sustained credit exhaustion is exactly what D4’s sustained pressure is for — and the instrument is wrong. The right response to this signal is probably Pro to Team (11,250 to 22,500 credits, and everything else doubles), which is a $40/month step and a far better fit for a percentage.
Example C — Team whose usage warrants Business, but with too few seats to justify it. The instinct is to offer Business at a price scaled to their seat count. Business is $249 for 8 seats, so $31.13 a seat; a four-seat account would pay $124.52 — which is, not coincidentally, about half price.
It is expressible, and it is still the wrong instrument. A discount is a
fixed value/unit with a window; DiscountDocument has no binding to a live
dimension and no re-evaluation hook. So a price “scaled to seats” is a price
scaled to the seats they had on the day it was minted:
The better answer is to compose, not to discount. A four-seat account does not want Business; it wants two or three things Business happens to include. Most of them are already purchasable from Team:
| Price | Team-eligible | |
|---|---|---|
pipeline_pack (+5 pipelines, +25 plugins) |
$29.99 | team, business, enterprise |
storage_pack (+10 GB) |
$19.99 | all tiers |
advanced_reporting |
$30.00 | developer, pro, team |
team_usage_analytics |
$30.00 | team, business |
A Team account short of pipelines, storage and reporting composes that for about $110/month on top of $79 — keeping four seats instead of paying for eight, with no discount, no cliff and no budget spent.
And the ladder is protected by an invariant that makes this safe rather than
exploitable. The bundle-sizing test holds one basket, summed across dimensions,
at or under the Team-to-Business delta of $170 — so composing the FULL Business
capacity is correctly more expensive than upgrading. Business’s API jump alone
(300k to 800k) is twenty api_packs at $239.80. Composing everything loses;
composing only the dimensions actually under pressure wins, and knowing which
those are is precisely what D4’s co-occurrence map is for.
So this example names a third offer type, not a third discount. Alongside upgrade the tier and buy a pack, there is compose a basket — and it is the right answer for exactly the account in this example: one whose usage has outgrown its tier in two or three dimensions while its headcount has not.
The real finding underneath is a catalogue gap, and a coupon should not fill it. There is no SKU for “a small team that needs large-tier features”, because tiers bundle capacity with seats. If that account type turns out to be common, the answer is a pricing-model change made deliberately — not an automated agent quietly inventing per-seat pricing one coupon at a time.
Which is the general lesson all three examples carry: the signal says an account is constrained; it does not say which SKU relieves it. D4’s headroom shape decides that, and getting it wrong in the cheap direction leaves money on the table while getting it wrong in the expensive direction is an overreach the account remembers.
One UsageProfile row per root org PER PERIOD, append-only — not one
mutable row per org, which is what an earlier draft described. Each row carries
the signals in D4 plus the period it was computed for.
This is the plan’s cheapest irreversible decision. Append-only costs nothing now; overwriting makes trajectory (D4 Class 2), self-relative baselines (D16), cohort comparison (D19) and calibration from history (D12) all impossible later — and impossible rather than merely harder, because you cannot reconstruct what an account looked like six months ago from a row that was overwritten. Every intelligence upgrade in this plan reads the history; the only chance to start keeping it is before the first cycle runs.
Retention is a separate question from existence: rows can be rolled up or aged out once their period is long past, but they have to have existed.
Persisted rather than computed on demand for the reason D2 gives: the evaluator loop cannot afford cross-service reads per subscription. It is also what makes the rules testable — a rule is a pure function of a profile.
Staleness is a first-class field, not an assumption. A profile older than its period is not a profile; D8 says skip the account rather than offer against figures that may predate a tier change.
The reads this phase depends on mostly do not exist yet, and that is the
phase’s real content. The quota service exposes no /internal/* routes at
all — just read-quotas, update-quota and usage-routes. D13 makes the
agent purely a CALLER, so every signal in D4 needs a callee that accepts a
service principal, and most of those endpoints have to be written. An earlier
draft of the sizing described this as “cross-service reads” as though the reads
were waiting; they are not.
Two consequences worth naming before the phase starts:
requireServicePrincipal), and a test — on quota,
image-registry and billing respectively.Two pieces of groundwork belong here and both have value without the agent:
QuotaCard.tsx into api-core (D4
Class 2), so the card, the threshold notice and the profile share one formula
instead of the page owning it privately.And the rules are testable as pure functions, which the plan should say
explicitly. A rule is a function of a UsageProfile (that is why Phase 0
persists one), so the test shape is a set of golden profiles — a handful of
hand-built accounts covering each co-occurrence pattern, each disqualifier, and
the crossover case — asserted against the offer each should produce, including
the ones that should produce none. In a repository with eighteen thousand tests,
a rule engine that decides discounts should not be the part with none.
And ship the crossover signal first (D4). It needs neither of the above, no discount, no campaign and no model — just arithmetic over the subscription — so it is the earliest point at which this plan can produce a defensible offer.
Widen PromotionConditions with a usage block and PromotionContext with the
profile, so matchesConditions remains the single place eligibility is decided.
Predicates worth having, and no more than these to start: sustained periods above a threshold on a named quota type, number of dimensions under pressure, add-ons held and not held, and subscription age.
One contract to respect, and an earlier draft of this plan got it backwards.
That draft said “an offer-driven grant is one-time by nature”. It is not: both
worked examples above are 50% for a year, which is kind: 'recurring' — and
batchEvaluatePromotion deliberately skips recurring promos, because
grantRecurringPromotions owns them and batch-granting one would double against
its one-time dedupeKey.
So the central offer shape runs on the periodic path, not the batch path. The dedupe key still has to incorporate the offer so a later offer to the same account can grant, and the recurring path’s own 12-month horizon has to be expressible — a discount that recurs forever is not the offer anybody intended to make.
The Offer record — org, offer type, SKU or basket, rationale (which signals
fired), amount, the campaign it spends from, status, expiry — plus the rule set,
the frequency cap, the suppression states and the holdout.
Three offer types, not one (D4’s crossover and Example C):
The basket needs to apply atomically, and today nothing does.
useAddonChange changes one add-on at a time, so a three-item basket is three
calls — and a partially-applied basket is worse than either end state: the
account has paid for part of an offer that was sized as a whole, and the
dimensions still under pressure are the ones whose purchase failed. Either apply
the basket in one transaction or present it as three explicit purchases, but do
not let a half-applied basket be a reachable state.
The record is the point. It is what the admin surface lists, what the audit trail references, what D12 retunes from, and what answers “why did this account get this?” without re-running anything.
The record is the point. It is what the admin surface lists, what the audit trail references, what the lift measurement counts, and what answers “why did this account get this?” without re-running anything.
The offer reaches the account where quota pressure already reaches it.
threshold-notifier owns that path — NOTIFY_EMAIL_PATH, the notice copy and
the quota.threshold webhook payload — and the offer should ride the same
surfaces rather than open a second channel the account has to learn.
The honest version of the copy names the limit, not the product. “Your plugin builds have been at the cap since the 3rd; a pack of 10 is $11.99” is a useful sentence. “Unlock more with Pro!” is the thing people filter.
The in-product surfaces are F4 and F5, and F1 gates all of it — an offer that cannot be displayed at its discounted price in the flow that accepts it is not deliverable, whatever the email says.
Write one sentence; read what the rules cannot. Reads the profile and the catalogue, never the grant path.
Ranking is the weakest use of it, and an earlier draft made ranking the headline. With D4’s co-occurrence map, D17’s option value and D18’s EV calculation, choosing among two or three permitted offers is arithmetic — a model there adds variance rather than insight, and variance in which offer an account receives is the one kind of inconsistency a customer can actually catch you at.
Point it instead at the thing the rules cannot do at all: turning unstructured evidence into a structured signal. Support ticket text is the clearest example — it will say an account is frustrated, which is a suppression signal (D18) that no counter can produce, and which the plan currently has no way to see. Build failure patterns are a second.
That reframing keeps the D5 boundary exactly where it was. The model produces a
SIGNAL; the rules decide. It never ranks an offer, sets an amount, or widens
eligibility — and it still must degrade cleanly, because aiCalls is itself a
metered quota and an AI outage must not stop expansion offers.
Identity still stays out of the prompt, which constrains this more than it constrains ranking did: ticket text is the most PII-dense material in the product, so what reaches the model is a derived sentiment or category, extracted by a path that keeps names and addresses on this side of it.
Keep account identity out of the prompt. The model needs the SHAPE of the usage, not who the account is: pressure by dimension, tier, add-ons held. Send figures, not names.
api/offersThe manifests, the NetworkPolicy, the Istio entry, the scrape target and the drift-test updates D3 enumerates. Everything above is testable before this.
Offered versus holdout, on conversion rate and on revenue per account, over a period long enough that the answer means something. Plus the counters an operator needs: offers made, suppressed and why, per reason (D6), declined, expired, redeemed, and budget consumed per campaign.
And the two measures that decide whether the targeting is right rather than merely active (D12): offered-SKU against bought-SKU, and time from offer to conversion. An account that converts to a DIFFERENT SKU is a targeting failure wearing a conversion win.
An earlier draft of this plan had no frontend section, which for a feature whose
entire value is realised in a UI is a hole rather than an omission. The
redemption and admin surfaces turn out to be largely built — DiscountRedeem,
DiscountDetailDrawer, PromotionDetailDrawer, MintDiscountModal,
CreateCampaignModal, CampaignSelect, AddonGrid, AddonPreviewModal,
PlanChangeModal, PlanGrid, CurrentChargesCard. What is missing is narrower
and sharper than “build a UI”.
F1 — PlanChangeModal cannot show a discount, so the headline offer cannot be
accepted at its stated price. This is a PREREQUISITE, not an enhancement.
PlanChangeModal contains no discount references at all, so an account that
accepts a 50%-off Business offer opens the upgrade modal and sees $249. Every
worked example in this plan dies at the last click until this is fixed.
F1b — and the preview is discount-aware only in the DESTRUCTIVE direction.
AddonPreviewModal carries exactly one discount line, and it is a warning that a
change ends one: “Ends your {name} discount — $X/mo off.” It tells an account
what it is about to lose and nothing about what it is about to gain.
That asymmetry is also a pre-flight requirement, not just a display bug: a compose basket or an upgrade can TERMINATE a discount the account already holds. D9 prices an offer before promising it, and this is part of that price — an offer that leaves the account worse off net is worse than no offer.
F2 — The quota card never says what a limit costs, and this is the cheapest
sales feature in the product. QuotaCard shows usage, limit, remaining, status
and a will-breach forecast. It carries no price, no pack and no add-on
information — every pack/price/$/addon match in the file is a template
literal inside a className.
The moment someone opens a quota card at 90% is the best sales moment the
product has, and the card currently says only that they are about to run out.
Adding “a pack of 10 is $11.99” needs no agent, no campaign, no discount and no
consent, because it is information rather than an offer. The precedent is
AIQuotaHint, which already does exactly this for AI calls: say the cost up
front instead of letting it be discovered as a refusal.
F3 — The crossover offer has no comparison surface. CurrentChargesCard
knows current charges including add-ons; PlanGrid and SelectablePlanCard show
plans. Nothing compares an account’s ACTUAL spend — packs included — against the
next tier’s price, which is the UI for the best offer in this plan (Team plus four
seat packs at $278.96 against Business at $249 for more of everything). Also
information rather than an offer, and also needs no agent.
F2 and F3 are worth building even if this plan is cancelled, which is the strongest argument for doing them first.
F4 — The redemption model is PULL; the agent needs PUSH. DiscountRedeem
takes a code the account already has; the drawers view one it already holds.
There is no surface where an offer APPEARS unprompted, so the agent currently
mints offers with nowhere to land. All of it is served by api/billing, not by
the agent (D13) — which is also where a user already goes for anything
commercial. Three parts, and the second matters most:
localStorage
dismissal is per-browser and per-device, so the same offer reappears on a
laptop. The cap and the UI’s memory have to be one record.F5 — Do not put offers in QuotaBanner, and decide that rather than drift into
it. It is the obvious push surface: app-wide, already iterating every quota on
the response. It is also the warning channel. Offers placed there compete with
genuine pressure warnings for the same real estate and train people to dismiss
the banner — degrading the warning channel in order to sell is a bad trade,
and the quota work already relies on that banner being believed.
F6 — The compose offer has no UI at all. AddonGrid sells one add-on at a
time (no multi-select, no basket) and AddonPreviewModal previews one. Example
C’s answer is a basket of three, sized to the dimensions under pressure, with a
combined total and one checkout. None of that exists, and composing it one add-on
at a time asks the account to assemble the offer itself.
F7 — No comms-preferences surface, but the card to put it on already exists.
D6 settles the control: offersEnabled, account-root, default true, gated on
billing:manage. The surface is QuotaNoticeSettingsCard, which already renders
the quota-notice enabled switch, the webhook URL and the thresholds for the same
account root — so this is one more row on a card that exists rather than a new
settings page.
Two things it must show that a bare toggle would not:
noticesEnabledOnInstance
already does — otherwise an operator-disabled feature looks like a broken one.api/reporting’s POST /public/report-recipients/unsubscribe remains the
pattern for the per-address path, including its reason for being a POST. But
someone should be able to turn offers off before being annoyed, not only
after: an unsubscribe link in an email is the floor, not the feature.
F8 — No admin visibility into the agent. A tab on BillingAdminTabs, served
by billing (D13) rather than by the agent, beside the manual tooling it already
holds. An automated granter needs its own view: offers made, suppressed and why
per reason, holdout membership, and budget burn-down per campaign. Without it,
“why did this account get 50% off?” has no answer in the UI — which is what the
Offer record was for in the first place.
Settled. That the existing engine is extended rather than replaced (D2);
that the rules decide money and the model never touches it, supplying signals
and copy rather than choices (D5); that a holdout exists from
the first cycle (D7); that the whole path fails closed (D8); that every offer is
priced through previewPromotion before it is promised (D9); and that the
instrument has to match the SKU’s price scale, which currently rules capacity
packs out (D10); that discount depth runs inverse to urgency (D11); and that
outcomes are logged and the rules retuned by a person rather than adjusted
online (D12); and that the service is internal only, exposing no public routes,
with billing serving every surface a customer or admin sees (D13); that the agent
holds a named service identity rather than acting as the system (D14); and that
its spend is bounded by rate as well as by total, with cycles made idempotent
(D15); that eligibility is a forecast against the account’s own baseline rather
than a threshold on the current period (D16); that the agent may hold a slot for
a better forecast offer (D17); that suppression prices annoyance rather than
listing it (D18); and that cohorts and per-signal incrementality are what keep
the signal set honest (D19).
Before Phase 1 ships, because they set contracts:
UsageProfile recompute interval, against the periods it summarises.
Flow quotas reset every three days and cpuCredits is planned monthly, so a
profile has to say which window each figure belongs to or “three consecutive
periods” is not well defined.Product judgements, answerable now but not by reading code:
Whether a tier offer may ever be made to an account a pack would serve — the overreach question in D4’s headroom shape.
dollar amount, or decide that packs are not offered
until something can grant free UNITS — which is an entitlement, and the
discount model refuses to grant entitlements by design. Until this is settled,
the tier offer is the only one worth shipping.The renewal cliff, and whether a year-long discount should exist at all.
Example A saves an account $54/month for a year and then raises it $70/month
above where it started. If the account grew into the seats it bought,
checkEntitlementOvercap refuses the downgrade until members are removed, so
the discount converts an account and then makes it feel trapped. The
alternatives worth weighing: a shallower permanent discount, a shorter window
with a smaller step, or the no-discount crossover offer (Team plus four packs
already costs more than Business) which survives renewal because nothing was
subsidised.
QuotaBanner. The banner is the easy answer and the
wrong one — it is the warning channel, and offers placed there compete with
genuine pressure warnings. Worth deciding explicitly, because drifting into
it is the likely default.The retune cadence (D12). How often the rules are revisited against the outcome log, and who owns the revision. Too frequent and it is online learning with extra steps; too rare and the measurement never reaches a rule.
PromotionContext already carries
interval, and “50% for a year” is twelve monthly credits to a monthly
account but one credit at renewal to an annual one — different mechanics,
different timing, and D4 Class 5’s renewal-proximity signal only means
anything for the annual case. A mid-period upgrade also prorates, and how the
credit interacts with a prorated charge is undecided.aws-marketplace-provider.ts and MarketplaceEntitlementsPanel exist, and
the discount model’s externalRef is described as provider-agnostic — but
whether a discount is actually deliverable on an AWS-Marketplace-billed
subscription is unverified. Answer it before offering to one; excluding the
class explicitly is a perfectly good answer.The per-cycle grant ceiling’s value (D15). Small enough that a bad cycle is embarrassing rather than expensive, which depends on how many accounts there are and how big the campaign budget is.
Profile retention (Phase 0). Append-only is settled; how long full-detail rows are kept before roll-up is not, and it interacts with the tier-aware retention the rest of the product already has.
storageBytes + plugins may mean “delete tags”
rather than “buy disk”, and the honest response to an account accumulating
images it does not need is GC advice. That advice is a non-commercial notice,
not an offer — so either storage is excluded from the offer set and routed to
the notice path, or the agent has to decide which of the two a given account
needs, which is a harder judgement than the rest of the map put together.Measurements, not decisions:
config.ts, so an offer landing on an
account that already holds a combo cannot compound into a giveaway.spentCents, grantsCount and
advisory-spend reconciliation from the ledger, so the agent needs no budget
machinery of its own.isActive invalidates every token minted for it, which is the kill switch an
automated granter needs and does not have to build.perOrgCapCents on the promotion.appliesToBundles. This is why “two
packs free” is not expressible and why D10 rules capacity-pack offers out for
now, rather than shipping an instrument worth $59 a year.campaigns, discounts and
promotions keep working by hand; this adds an automated caller, not a
replacement.docs/plans/cpu-credits.md adds a metered dimension that is unlike every other
quota in the catalogue: it is compute the platform actually pays for, not a
rate limit protecting shared infrastructure. That makes it both the most
expensive thing an account can consume and the sharpest signal of what it is
doing, so the two plans feed each other.
What cpuCredits gives this plan once it ships:
plugins + cpuCredits becomes the highest-confidence signature in the
co-occurrence map. The two are derived from each other in the credits plan
(credits come from plugins × 10 × wall-minutes × vCPU), so pressure on both
together is a heavy build shop rather than a coincidence — and the instrument
is pipeline_pack or the tier, not a credits pack.What this plan has to absorb from the credits plan’s own decisions:
And one dependency runs the other way. The credits plan’s F2 — stating what a limit costs on the quota card — is this plan’s F2. Whichever ships first should build it for every quota type rather than for its own dimension, or the second one re-implements it.
| Phase | Size | Why |
|---|---|---|
| 0 | Medium–Large | A profile model, a scheduler on the proven createScheduler + leaderLockKey pattern, and the internal read endpoints the reads depend on — the quota service has none today, so most are new routes with service-principal gates and tests on services this plan does not own. Plus two pieces of groundwork with standalone value: extracting the burn-rate projection out of QuotaCard.tsx into api-core, and the per-member activity stamp. The crossover signal ships here and needs neither |
| 1 | Small–Medium | A usage block on the conditions, the context widened, and matchesConditions extended — plus the dedupe-key decision, and the recurring path rather than the batch path, since a year-long offer is kind: 'recurring' |
| 2 | Medium–Large | The Offer record, the rule set, frequency caps, suppression states, the holdout, the previewPromotion pre-flight (D9), the EV calculation (D18) and the hold decision (D17). The last two are small code and the plan’s most consequential judgement calls |
| 3 | Small | Rides threshold-notifier’s existing notice and webhook surfaces; the work is copy and the unsubscribe token |
| 4 | Medium | Optional by construction, and must degrade cleanly. Repointed from ranking to unstructured-to-structured extraction, which is more valuable and more PII-sensitive — the extraction path, not the model, is the work |
| 5 | Small–Medium | A tenth service across three Kubernetes trees plus docker, NetworkPolicy and a metrics scrape — but NO ingress, auth middleware or route-table entry, since D13 gives it no public surface. Offset by its being the repo’s first route-less service, so the manifests have no exact template |
| 6 | Medium | Counters, per-reason suppression, the holdout comparison, the outcome log D12 retunes from, cohort construction and the per-signal incrementality comparison (D19) |
| Frontend | Medium | F1 is a PREREQUISITE (PlanChangeModal shows no discount, so the offer cannot be accepted at its price). F2 and F3 are standalone wins needing no agent. F4 is the real new surface — push, a decline verb, server-side dismissal. F6 is a basket flow AddonGrid has no shape for. F7 and F8 are a settings surface and an admin tab |
Conversion and revenue per account, offered against holdout. Plus the signals that tell you it is behaving rather than merely running: suppression reasons by count (a feature suppressing almost everything is misconfigured, not careful), offers expiring unredeemed, unsubscribe rate, and budget consumed per campaign against grants made.
Two more that only exist once the history does: cohort conversion (D19), and whether held slots (D17) were vindicated — a hold that was followed by a larger converted offer was right, and one followed by nothing was the agent talking itself out of a sale.
The unsubscribe rate is the one to watch first. It is the only metric here that measures whether the account experienced this as useful or as spam, and it is the one that cannot be fixed later by tuning a threshold.
The first slice before any of this — F1, F2, F3 and the crossover offer, per the section near the top. None of it needs the agent, and three of the four are information rather than solicitation.
Append-only profiles from the very first cycle (Phase 0). It is the only decision here that cannot be revisited: history not kept is history not recoverable, and D16, D19 and D12’s calibration all read it. Everything else in this plan can be added later at roughly the same cost; this one cannot be added at all.
D14 and D15 before the first live cycle, not after. A named actor, a per-cycle ceiling and an idempotency key are what separate a scheduler that grants money safely from one that merely grants money, and all three are far cheaper to add before there are grants in the ledger to reconcile against.
F1 before anything ships to a customer, because until PlanChangeModal can
show a discount, every offer in this plan is a promise the checkout contradicts.
It is also the smallest item in the set.
Then F2 and F3, which are not really part of this plan at all. Telling people what a limit costs on the page where they look at the limit, and showing an account its real spend against the next tier, are both pure information. They need no agent, no campaign, no discount and no consent — and if the agent is never built they are still the right thing to have done.
The crossover offer first among the offer types, before any of the phases. It is arithmetic over the subscription record: no profile, no discount, no campaign, no model, no consent question that a billing notice does not already raise. It is the cheapest proof that the premise holds — that there are accounts whose spend already argues for a tier move — and if there are none, that is worth knowing before Phase 0.
Then Phase 0, and alone, because the signals are the risky part. Every rule in Phase 2 is a function of a profile, so a wrong profile makes every rule wrong in a way that looks like a rule problem. Build the store, look at real profiles for a period, and only then write rules against them.
D7’s holdout lands with Phase 2, not after. It is cheap, and it is unrecoverable if skipped — a holdout added later cannot measure the launch.
Phase 3 before Phase 4. Delivery with deterministic copy is shippable and measurable; the model is an improvement on a working thing, not a prerequisite for it. Shipping 4 first would make an AI outage an expansion outage.
Phase 5 whenever the deployment cost is worth paying. Nothing above needs it, and running Phases 0–3 as a billing scheduler is the cheaper place to discover that a rule is wrong.