@suluk/cost 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <h1 align="center">@suluk/cost</h1>
8
8
 
9
- <p align="center"><b>Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is.</b></p>
9
+ <p align="center"><b>Cost as a contract facet, plus a runtime meter for what every request actually cost traced from the frontend action down to each third party.</b></p>
10
10
 
11
11
  <p align="center">
12
12
  <em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
@@ -24,11 +24,180 @@
24
24
  bun add @suluk/cost
25
25
  ```
26
26
 
27
- ## The Suluk cycle
27
+ `hono` is an **optional** peer dependency — needed only for the request-time middleware (`costMeter`,
28
+ `recordUsage`). The contract, event, and ledger helpers have no runtime deps beyond `@suluk/core`.
28
29
 
29
- `@suluk/cost` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
30
- preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
31
- [main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
30
+ ## What it does
31
+
32
+ You can't price a user without knowing what they cost you. `@suluk/cost` makes cost a first-class,
33
+ declared part of the contract — and then meters the real thing:
34
+
35
+ - **Declare cost on the contract.** Attach a `CostModel` to each operation as the `x-suluk-cost`
36
+ vendor extension. Like every Suluk facet it bubbles: it survives the 3.1 downgrade (3.1 keeps
37
+ `x-*`), Scalar/Swagger render it, and a coverage **audit** can flag operations that never declared
38
+ a cost (unknown ≠ assumed zero).
39
+ - **Meter the actual cost per request.** A Hono middleware records what each request *did* cost —
40
+ fixed (per-call) components plus metered third-party usage the handler reported — attributed all the
41
+ way down: frontend action → operation → per-source breakdown.
42
+ - **Bill background events too.** A fired webhook / cron tick / queue message has no live caller, so a
43
+ separate Context-free path resolves **who pays** (a runtime attribution expression), dedupes
44
+ at-least-once delivery, and reconciles the declared estimate against the third party's *actual*
45
+ charge read from the event payload.
46
+ - **Read the raw ledger.** Aggregate cost events by principal, operation, action, and source. All money
47
+ is integer **micro-USD** (1 USD = 1,000,000 µ$) — the rawest representation. We display it as-is and
48
+ let you build pricing on top.
49
+
50
+ ## When to reach for it
51
+
52
+ - Any metered or usage-priced API — anything where a single user can cost you real money (LLM tokens,
53
+ egress, downstream third-party calls) and you need the per-user picture to price them.
54
+ - When you want cost to be *auditable* in CI: `costAudit` is the coverage gate (every money-moving op
55
+ must declare what it costs).
56
+ - Pairs with **`@suluk/stripe`** for the billing side — `@suluk/cost` produces the raw ledger; Stripe
57
+ turns it into invoices/metered subscriptions. This package never imposes pricing, margins, or limits.
58
+
59
+ ## Usage
60
+
61
+ ### 1. Declare cost as a contract facet
62
+
63
+ ```ts
64
+ import { annotateCosts, costAudit, costTable, type CostModel } from "@suluk/cost";
65
+ import { emitV4 } from "@suluk/hono";
66
+
67
+ const ask: CostModel = {
68
+ components: [
69
+ { source: "compute", basis: "per-call", microUsd: 50 },
70
+ { source: "openai", basis: "per-1k-tokens", microUsd: 2000, description: "$0.002 / 1k tokens" },
71
+ ],
72
+ estimateMicroUsd: 1050, // typical total for display/tests before usage is known
73
+ };
74
+
75
+ const { document } = emitV4(/* operations… */);
76
+
77
+ // Set x-suluk-cost on each named operation (returns a new doc; covers paths + webhooks).
78
+ const annotated = annotateCosts(document, { ask });
79
+
80
+ // Coverage audit — which operations never declared a cost (warns), plus background-cost disciplines.
81
+ for (const f of costAudit(annotated)) console.warn(f.code, f.operation, f.message);
82
+
83
+ // The declared costs, raw, for an admin/cockpit table.
84
+ console.table(costTable(annotated)); // [{ operation, path, estimateMicroUsd, sources, trigger }]
85
+ ```
86
+
87
+ ### 2. Meter the actual cost at runtime (Hono)
88
+
89
+ ```ts
90
+ import { costMeter, recordUsage, MemoryCostSink } from "@suluk/cost";
91
+ import { Hono } from "hono";
92
+
93
+ const sink = new MemoryCostSink(); // swap in D1 / a queue in production
94
+ const app = new Hono<{ Variables: { operation: string; principal: string } }>();
95
+
96
+ app.use("*", costMeter({
97
+ sink,
98
+ costs: { ask }, // operation name → declared CostModel
99
+ operationOf: (c) => c.get("operation"), // resolve the op name for this request
100
+ principalOf: (c) => c.get("principal"), // resolve the user id (optional)
101
+ // actionHeader defaults to "x-suluk-action"; now defaults to () => Date.now()
102
+ }));
103
+
104
+ app.post("/ask", (c) => {
105
+ recordUsage(c, "openai", 2000); // report MEASURED third-party usage for THIS request
106
+ return c.json({ answer: "42" });
107
+ });
108
+ // → records a CostEvent: { operation: "ask", principal, action, breakdown, totalMicroUsd }
109
+ ```
110
+
111
+ `computeCost(model, usage)` is the pure core the meter uses — call it directly to get the
112
+ `{ breakdown, totalMicroUsd }` for a model + measured usage, e.g. for previews or tests.
113
+
114
+ ### 3. Bill a background event (webhook / cron / queue)
115
+
116
+ A fired event has no Hono `Context`, so it gets a Context-free path. The model declares **when** it
117
+ fires (`trigger`), **who pays** (`attribution`), and how it **reconciles** with the actual charge:
118
+
119
+ ```ts
120
+ import { recordEventCost, type CostModel } from "@suluk/cost";
121
+
122
+ // Stripe fires payment_intent.succeeded → it charged you, attributed to the customer.
123
+ const chargeModel: CostModel = {
124
+ components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
125
+ trigger: "webhook-received",
126
+ attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
127
+ idempotencyKey: "{$event.id}", // dedupe at-least-once delivery
128
+ reconciliationBasis: "payload-reconciled",
129
+ amountExpression: "{$event.body#/amount}", // read the ACTUAL charge from the payload…
130
+ amountUnit: "cents", // …in cents (Stripe) → ×10_000 into µ$
131
+ };
132
+
133
+ const event = { id: "evt_123", type: "payment_intent.succeeded", body: { customer: "cus_42", amount: 2900 } };
134
+ const seen = new Set<string>(); // an in-memory dedupe store for dev; a durable KV/DO in prod
135
+
136
+ // Resolves the principal, dedupes by idempotencyKey, and records — returns null on a redelivery.
137
+ const recorded = await recordEventCost(
138
+ sink,
139
+ { operation: "stripeCharge", model: chargeModel, event, at: Date.now() },
140
+ seen,
141
+ );
142
+ ```
143
+
144
+ > Security: an `event-expression` off an **unverified** payload is attacker-controllable. Gate it
145
+ > behind a verified webhook signature and set `trust: "verified"` — `costAudit` flags
146
+ > `unverified-attribution` otherwise. A cost that resolves no principal bills to the `UNATTRIBUTED`
147
+ > (`@unattributed`) sentinel — fail loud, never silent.
148
+
149
+ ### 4. Read the raw ledger
150
+
151
+ ```ts
152
+ import { summarize, principalCost, formatMicroUsd } from "@suluk/cost";
153
+
154
+ const events = sink.events();
155
+ const totals = summarize(events);
156
+ // { total, count, byPrincipal, byOperation, byAction, bySource } — all in µ$
157
+
158
+ console.log("what did user_42 cost me?", formatMicroUsd(principalCost(events, "user_42").total));
159
+ ```
160
+
161
+ ## API
162
+
163
+ | Export | What it does |
164
+ | --- | --- |
165
+ | `annotateCosts(doc, costs)` | Set `x-suluk-cost` on each named operation; returns a new doc. |
166
+ | `costOf(req)` / `triggerOf(model)` / `isDeferredCost(model)` | Read a request's model; its trigger (default `synchronous`); whether the cost is a background event. |
167
+ | `costAudit(doc)` | Coverage + discipline audit → `CostFinding[]` (no-cost-model, zero-cost, unattributed/unverified background cost, reconciliation-incomplete). |
168
+ | `costTable(doc)` | The declared costs (paths + webhooks + jobs) as `CostRow[]` for display. |
169
+ | `eachOperation(doc)` / `eachJob(doc)` | Walk every cost locus — path requests, webhooks, and C025 jobs. |
170
+ | `computeCost(model, usage)` | Pure: `{ breakdown, totalMicroUsd }` from a model + measured usage. |
171
+ | `costMeter(opts)` | Hono middleware that records a `CostEvent` per request. |
172
+ | `recordUsage(c, source, units)` | A handler reports measured third-party usage for the current request. |
173
+ | `MemoryCostSink` / `CostSink` | In-memory sink for dev/tests; the `record(event)` port you implement for prod. |
174
+ | `resolveEventExpression(expr, event)` | Resolve a `{$event.…}` runtime-expression (top-level key or JSON-Pointer) against a fired event. |
175
+ | `attributePrincipal(model, event, supplied?)` | Resolve who pays for a fired event; `@unattributed` when nothing resolves. |
176
+ | `reconciledAmount(model, event)` | The actual charge (µ$) read from the payload when `payload-reconciled`. |
177
+ | `eventCostEvent(input)` / `recordEventCost(sink, input, seen?)` | Build / record a background `CostEvent` (deduped by `idempotencyKey`). |
178
+ | `summarize(events)` / `principalCost(events, principal)` | Aggregate the ledger; one principal's slice. |
179
+ | `formatMicroUsd(µ$)` | Display µ$ as a `$` string (storage stays integer). |
180
+ | `COST_EXT` / `UNATTRIBUTED` | The `x-suluk-cost` extension key; the no-principal sentinel. |
181
+
182
+ The cost-model vocabulary: `CostBasis` (`per-call`, `per-unit`, `per-token`, `per-1k-tokens`,
183
+ `per-second`, `per-request`, `per-mb`), `CostTrigger` (`synchronous`, `webhook-received`, `scheduled`,
184
+ `queue-consumed`, `callback-completed`), `CostAttribution`, and `ReconciliationBasis` — three
185
+ orthogonal axes: `basis` = HOW it meters, `trigger` = WHEN it fires, `attribution` = WHO pays.
186
+
187
+ ## Boundary
188
+
189
+ This package **measures and displays** cost — it does not price, charge, or persist. It stays
190
+ honestly raw on purpose:
191
+
192
+ - **Inject the sink.** `costMeter`/`recordEventCost` write `CostEvent`s to a `CostSink` *you* provide.
193
+ `MemoryCostSink` is for dev/tests; production swaps in D1, a queue, or a Durable Object. The
194
+ package never opens a database.
195
+ - **Inject the inputs.** Wall-clock (`now` / `at`), the principal resolver, the dedupe store (`seen`),
196
+ and the operation matcher are all passed in — so events are reproducible and testable, and nothing
197
+ reads ambient state.
198
+ - **Pricing lives downstream.** Margins, plans, limits, and invoices are the consumer's to build —
199
+ typically via **`@suluk/stripe`**, which turns this ledger into metered billing. `@suluk/cost`
200
+ stops at the raw µ$ picture.
32
201
 
33
202
  ## License
34
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/cost",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,7 +19,7 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.7"
22
+ "@suluk/core": "^0.1.13"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "hono": "^4.0.0"
@@ -32,8 +32,8 @@
32
32
  "devDependencies": {
33
33
  "@types/bun": "latest",
34
34
  "hono": "^4.0.0",
35
- "@suluk/hono": "^0.1.2",
36
- "@suluk/openapi-compat": "^0.1.2",
35
+ "@suluk/hono": "^0.1.5",
36
+ "@suluk/openapi-compat": "^0.1.3",
37
37
  "zod": "^4.4.3"
38
38
  },
39
39
  "scripts": {
package/src/index.ts CHANGED
@@ -11,7 +11,14 @@ export {
11
11
  type CostTrigger, type CostAttribution, UNATTRIBUTED,
12
12
  // C026 — reconciliation: declared-estimate vs the third party's actual (payload-reconciled) charge.
13
13
  type ReconciliationBasis,
14
+ // C044 — settlement: HOW the cost is recovered (credit | rate-limited | free).
15
+ type SettlementMethod, type CostSettlement,
14
16
  } from "./types";
17
+ // C044 — settlement audit (every priced op names a lever) + the errors a request's facets imply.
18
+ export {
19
+ settlementOf, settlementAudit, impliedErrorStatuses, settlementRollup,
20
+ type SettlementFinding, type SettlementSeverity, type SettlementRollup,
21
+ } from "./settlement";
15
22
  export {
16
23
  COST_EXT, annotateCosts, costOf, costAudit, costTable, computeCost, type CostFinding,
17
24
  eachOperation, eachJob, triggerOf, isDeferredCost, type CostRow,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cost SETTLEMENT (C044) — how a declared cost is RECOVERED from the user. The fifth orthogonal cost axis. Promotes a
3
+ * real cowpath: toolfactory's governance gate already checks "every cost names a lever — credit | rate-limit | free";
4
+ * this makes that a first-class, Suluk-derived facet. Also derives the HTTP errors a request's facets IMPLY (the
5
+ * generic form of toolfactory's errors-gate). Pure functions of the declared facets — never a request value.
6
+ */
7
+ import type { OpenAPIv4Document, Request } from "@suluk/core";
8
+ import { eachOperation, costOf } from "./contract";
9
+ import type { CostModel, CostSettlement } from "./types";
10
+
11
+ const ext = (req: Request) => req as Request & Record<string, unknown>;
12
+
13
+ /** The settlement declared on an operation's cost. */
14
+ export function settlementOf(req: Request): CostSettlement | undefined {
15
+ return costOf(req)?.settlement;
16
+ }
17
+
18
+ const isPriced = (cost: CostModel | undefined): boolean =>
19
+ !!cost && (((cost.estimateMicroUsd ?? 0) > 0) || (cost.components ?? []).some((c) => (c.microUsd ?? 0) > 0));
20
+
21
+ export type SettlementSeverity = "high" | "medium" | "low";
22
+ export interface SettlementFinding {
23
+ rule: string;
24
+ severity: SettlementSeverity;
25
+ operation: string;
26
+ path: string;
27
+ message: string;
28
+ fix: string;
29
+ }
30
+
31
+ /**
32
+ * Audit that every PRICED operation names HOW it is settled, and that the named lever is coherent — the generic form of
33
+ * toolfactory's "cost names a lever" governance check.
34
+ */
35
+ export function settlementAudit(doc: OpenAPIv4Document): SettlementFinding[] {
36
+ const findings: SettlementFinding[] = [];
37
+ for (const { path, name, req } of eachOperation(doc)) {
38
+ const cost = costOf(req);
39
+ const s = cost?.settlement;
40
+ const add = (rule: string, severity: SettlementSeverity, message: string, fix: string) => findings.push({ rule, severity, operation: name, path, message, fix });
41
+
42
+ if (isPriced(cost) && !s) {
43
+ add("cost-without-settlement", "medium", `priced op '${name}' does not name how its cost is paid`, "add x-suluk-cost.settlement: { method: 'credit' | 'rate-limited' | 'free' }");
44
+ }
45
+ if (s?.method === "rate-limited" && !ext(req)["x-suluk-ratelimit"]) {
46
+ add("rate-limited-without-cap", "high", `op '${name}' is settled by rate-limiting but declares no x-suluk-ratelimit — there is no cap to BE the payment`, "add an x-suluk-ratelimit (the free-tier cap), or change settlement.method");
47
+ }
48
+ if (s?.method === "credit" && s.credits == null && !cost?.estimateMicroUsd) {
49
+ add("credit-without-amount", "medium", `op '${name}' is settled by credit but declares neither settlement.credits nor an estimateMicroUsd to debit`, "set settlement.credits (or x-suluk-cost.estimateMicroUsd) so the runtime knows the debit");
50
+ }
51
+ if (s?.method === "free" && isPriced(cost)) {
52
+ add("free-but-priced", "low", `op '${name}' is settled as free yet declares a positive cost — the operator absorbs it`, "confirm intended, or change settlement.method to credit / rate-limited");
53
+ }
54
+ }
55
+ return findings;
56
+ }
57
+
58
+ /**
59
+ * The HTTP error statuses a request's FACETS imply (the generic form of toolfactory's errors-gate): a contract should
60
+ * declare these responses. credit→402 · authenticated/admin→401 · owner-scope→403 · rate-limit→429 · an upstream
61
+ * third-party call (a `per-request` cost component)→502. A pure function of the declared facets.
62
+ */
63
+ export function impliedErrorStatuses(req: Request): number[] {
64
+ const out = new Set<number>();
65
+ const cost = costOf(req);
66
+ const access = ext(req)["x-suluk-access"] as { requires?: string; scope?: string } | undefined;
67
+ if (cost?.settlement?.method === "credit") out.add(402);
68
+ if (access?.requires === "authenticated" || access?.requires === "admin") out.add(401);
69
+ if (access?.scope) out.add(403);
70
+ if (ext(req)["x-suluk-ratelimit"]) out.add(429);
71
+ if ((cost?.components ?? []).some((c) => c.basis === "per-request")) out.add(502);
72
+ return [...out].sort((a, b) => a - b);
73
+ }
74
+
75
+ export interface SettlementRollup {
76
+ credit: number;
77
+ ["rate-limited"]: number;
78
+ free: number;
79
+ /** priced ops with NO settlement declared (the gap). */
80
+ unsettled: number;
81
+ }
82
+
83
+ /** A quick "how is this API monetized" tally — ops grouped by settlement method (+ priced-but-unsettled). */
84
+ export function settlementRollup(doc: OpenAPIv4Document): SettlementRollup {
85
+ const r: SettlementRollup = { credit: 0, "rate-limited": 0, free: 0, unsettled: 0 };
86
+ for (const { req } of eachOperation(doc)) {
87
+ const cost = costOf(req);
88
+ const m = cost?.settlement?.method;
89
+ if (m) r[m]++;
90
+ else if (isPriced(cost)) r.unsettled++;
91
+ }
92
+ return r;
93
+ }
package/src/types.ts CHANGED
@@ -79,11 +79,32 @@ export interface CostModel {
79
79
  amountExpression?: string;
80
80
  /** the unit `amountExpression` yields (default "micro-usd"). "cents" (Stripe) → ×10_000; "usd" → ×1_000_000. */
81
81
  amountUnit?: "micro-usd" | "cents" | "usd";
82
+ /** HOW the operator RECOVERS this cost (C044). The fifth orthogonal axis — basis=how-meters · trigger=when-fires ·
83
+ * attribution=who-pays · reconciliation=declared-vs-actual · **settlement=how-recovered**. */
84
+ settlement?: CostSettlement;
82
85
  }
83
86
 
84
87
  /** Whether a cost's amount is a declared guess or read from the event payload at runtime (C026). */
85
88
  export type ReconciliationBasis = "declared-estimate" | "payload-reconciled";
86
89
 
90
+ /**
91
+ * HOW a declared cost is RECOVERED from the user (C044). `rate-limited` ⇒ free to the user — the cost is "paid" by
92
+ * CAPPING usage, so the op's `x-suluk-ratelimit` IS the settlement (no money moves). `credit` ⇒ the user pays credits
93
+ * (a balance is debited). `free` ⇒ truly free (the operator absorbs any cost). A purely STATIC fact (an enum + an
94
+ * integer + names) — never a request value, so it rides the x-suluk-cost wall (matcher-invisible since C024).
95
+ */
96
+ export type SettlementMethod = "credit" | "rate-limited" | "free";
97
+
98
+ export interface CostSettlement {
99
+ method: SettlementMethod;
100
+ /** method:"credit" — the credits debited per call (a non-negative integer). Omitted ⇒ derived from
101
+ * `estimateMicroUsd` × the operator's credit rate (a runtime concern, not declared here). */
102
+ credits?: number;
103
+ /** method:"rate-limited" — what happens when the free cap (`x-suluk-ratelimit`) is exhausted: refuse, or fall back
104
+ * to charging credits. Advisory; the runtime enforces it. */
105
+ overflow?: "deny" | "credit";
106
+ }
107
+
87
108
  /** The principal sentinel for a background cost that resolved to NO principal — billed to nobody, but never silent. */
88
109
  export const UNATTRIBUTED = "@unattributed" as const;
89
110
 
@@ -0,0 +1,91 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { settlementOf, settlementAudit, impliedErrorStatuses, settlementRollup, type CostSettlement } from "../src/index";
3
+ import type { OpenAPIv4Document, Request } from "@suluk/core";
4
+
5
+ /**
6
+ * C044 — cost settlement (HOW a cost is recovered: credit | rate-limited | free). The fifth orthogonal cost axis.
7
+ * settlementAudit is the generic form of toolfactory's "cost names a lever" governance check; impliedErrorStatuses is
8
+ * the generic errors-gate. All pure functions of the declared facets — never a request value (rides the x-suluk-cost wall).
9
+ */
10
+ const op = (cost: unknown, extra: Record<string, unknown> = {}): Request => ({ method: "post", "x-suluk-cost": cost, ...extra }) as unknown as Request;
11
+ const doc = (requests: Record<string, Request>): OpenAPIv4Document => ({ openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests } } }) as unknown as OpenAPIv4Document;
12
+
13
+ describe("settlementOf", () => {
14
+ test("reads the settlement off the cost facet", () => {
15
+ expect(settlementOf(op({ estimateMicroUsd: 1000, settlement: { method: "credit", credits: 10 } }))).toEqual({ method: "credit", credits: 10 });
16
+ expect(settlementOf(op({ estimateMicroUsd: 1000 }))).toBeUndefined();
17
+ });
18
+ });
19
+
20
+ describe("settlementAudit — every priced op names a coherent lever", () => {
21
+ test("a priced op with no settlement is flagged", () => {
22
+ const f = settlementAudit(doc({ priced: op({ estimateMicroUsd: 200 }) }));
23
+ expect(f.map((x) => x.rule)).toContain("cost-without-settlement");
24
+ });
25
+
26
+ test("rate-limited WITHOUT an x-suluk-ratelimit is a high finding (no cap to be the payment)", () => {
27
+ const f = settlementAudit(doc({ bad: op({ estimateMicroUsd: 500, settlement: { method: "rate-limited" } }) }));
28
+ const hit = f.find((x) => x.rule === "rate-limited-without-cap");
29
+ expect(hit?.severity).toBe("high");
30
+ });
31
+
32
+ test("rate-limited WITH a cap is clean", () => {
33
+ const f = settlementAudit(doc({ ok: op({ estimateMicroUsd: 0, components: [], settlement: { method: "rate-limited" } }, { "x-suluk-ratelimit": { windowMs: 1000, maxRequests: 10, key: "ip" } }) }));
34
+ expect(f.some((x) => x.rule === "rate-limited-without-cap")).toBe(false);
35
+ });
36
+
37
+ test("credit with neither credits nor an estimate is flagged", () => {
38
+ const f = settlementAudit(doc({ c: op({ components: [{ source: "x", basis: "per-call", microUsd: 5 }], settlement: { method: "credit" } }) }));
39
+ expect(f.map((x) => x.rule)).toContain("credit-without-amount");
40
+ });
41
+
42
+ test("free-but-priced is a low finding (operator absorbs it)", () => {
43
+ const f = settlementAudit(doc({ f: op({ estimateMicroUsd: 300, settlement: { method: "free" } }) }));
44
+ const hit = f.find((x) => x.rule === "free-but-priced");
45
+ expect(hit?.severity).toBe("low");
46
+ });
47
+
48
+ test("a well-formed credit op raises nothing", () => {
49
+ const f = settlementAudit(doc({ good: op({ estimateMicroUsd: 1000, settlement: { method: "credit", credits: 10 } }) }));
50
+ expect(f).toEqual([]);
51
+ });
52
+ });
53
+
54
+ describe("impliedErrorStatuses — errors a request's facets imply", () => {
55
+ test("credit→402, authenticated→401, owner-scope→403, rate-limit→429", () => {
56
+ const r = op({ settlement: { method: "credit", credits: 5 } }, { "x-suluk-access": { requires: "authenticated", scope: "owner-only" }, "x-suluk-ratelimit": { windowMs: 1, maxRequests: 1, key: "ip" } });
57
+ expect(impliedErrorStatuses(r)).toEqual([401, 402, 403, 429]);
58
+ });
59
+
60
+ test("an upstream per-request cost component implies 502", () => {
61
+ expect(impliedErrorStatuses(op({ components: [{ source: "openai", basis: "per-request", microUsd: 100 }] }))).toEqual([502]);
62
+ });
63
+
64
+ test("a public, free op implies no error statuses", () => {
65
+ expect(impliedErrorStatuses(op({ settlement: { method: "free" } }))).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe("settlementRollup — how the API is monetized", () => {
70
+ test("tallies ops by method + counts priced-but-unsettled", () => {
71
+ const d = doc({
72
+ a: op({ estimateMicroUsd: 100, settlement: { method: "credit", credits: 1 } }),
73
+ b: op({ components: [], settlement: { method: "rate-limited" } }, { "x-suluk-ratelimit": { windowMs: 1, maxRequests: 1, key: "ip" } }),
74
+ c: op({ estimateMicroUsd: 50 }), // priced, no settlement
75
+ });
76
+ expect(settlementRollup(d)).toEqual({ credit: 1, "rate-limited": 1, free: 0, unsettled: 1 });
77
+ });
78
+ });
79
+
80
+ describe("D1 wall — settlement carries only STATIC facts, never a request-value selector", () => {
81
+ // TYPE-LINKED: every CostSettlement field classifies as an enum or a scalar — none extracts a request VALUE (no
82
+ // expression / pointer). Adding a value-extracting field fails to compile here until classified (the C037 discipline).
83
+ const KIND: Record<keyof CostSettlement, "enum" | "scalar"> = { method: "enum", credits: "scalar", overflow: "enum" };
84
+ test("no settlement field is a runtime value-expression/pointer", () => {
85
+ for (const k of Object.keys(KIND)) expect(["enum", "scalar"]).toContain(KIND[k as keyof CostSettlement]);
86
+ // a populated settlement holds an enum + an integer + an enum — nothing that points into a payload.
87
+ const full: CostSettlement = { method: "rate-limited", credits: 10, overflow: "credit" };
88
+ expect(typeof full.method).toBe("string");
89
+ expect(Number.isInteger(full.credits)).toBe(true);
90
+ });
91
+ });