Skip to content

Pin the pricing arithmetic a second implementation cannot currently derive - #2

Open
Ethan-Arrowood wants to merge 4 commits into
mainfrom
spec-pricing
Open

Ethan-Arrowood wants to merge 4 commits into
mainfrom
spec-pricing

Conversation

@Ethan-Arrowood

@Ethan-Arrowood Ethan-Arrowood commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Read SPEC.md as someone writing the Fastify + Postgres + Redis implementation from it alone. Six things in the pricing path can't be derived, and one isn't underspecified but self-contradictory. AGENTS.md says a requirement that couldn't be satisfied on that stack is mis-specified, so these are defects rather than polish.

The contradiction

The POST /cart/:id/quote section states that all discounts draw on one budget, the line's remaining amount — then two paragraphs later states that cart-wide and per-line discounts draw on separate budgets. Both are in the normative part of that section.

The implementation has one budget: drawDown serves exclusive, threshold, BOGO and stackable alike, and the cart-level accumulator survives only in a stale header comment. Removed the stale half of each.

The six gaps

Gap What a second implementation does differently
1 Tier multipliers were nowhere in the spec. QUOTE-004 said tier is "applied to pricing" without saying by how much. The values lived in resources/lib/pricing.js, duplicated in packages/seed/src/vocabulary.ts with nothing checking they match Cannot derive them. Every unit price of every line differs — the whole comparison is invalid before pricing logic runs
2 BOGO magnitude. Step 4 said which unit, never how much. The implementation discounted the full unit price and ignored the promotion's own amountBasisPoints Applies the promotion's percentage instead. Agrees today only because every bogo row carries 10000 basis points; diverges silently the moment one doesn't
3 thresholdMinor gates every kind, not just the threshold kind. The implementation does this; the spec mentioned it only under threshold Applies a stackable carrying a threshold regardless of subtotal
4 The cap is a running bound, not a final truncation Truncating at the end leaves appliedPromotionIds citing promotions whose discount was scaled away — which QUOTE-012 forbids — and makes per-line figures stop summing to the cart total
5 A shortfall doesn't reduce the priced quantity Prices only what's in stock, making the total a function of inventory the background writer is continuously changing — totals drift under load for reasons unrelated to architecture
6 Shipping band boundaries — inclusivity, overlap precedence, and a silent 0 when nothing matches Picks different boundary semantics

On (6) I specified the data contract rather than a fallback: a new Rate table invariants subsection under Data model now states that bands are contiguous, non-overlapping, inclusive at both ends and cover every weight — which the dataset already satisfies. Exactly one band matches, so no fallback is reachable, and an implementation finding zero or several has loaded a bad dataset and should fail loudly.

One implementation change

pricing.js now reads the BOGO magnitude from the promotion instead of assuming the full unit price. Provably behaviour-identical on this corpus: every bogo row carries amountBasisPoints: 10000 and amountMinor: 0, and applying 10000 basis points to an integer returns it unchanged — floor((u × 10000 + 5000) / 10000) = floor(u + 0.5) = u. It makes the code agree with the spec permanently rather than coincidentally.

What this does not do

No requirement ids added or withdrawn. These clarify existing MUSTs rather than impose new ones, so the coverage gate is unchanged — npm run check passes, 32 ids both sides, every MUST covered.

Two of these were things I'd initially read as wrong behaviour rather than unstated behaviour. On inspection both current choices are right and the spec was simply silent: pricing the full requested quantity keeps totals independent of the background writer, and continuous cap enforcement is what keeps attribution honest. The amendments state the reasoning so the next reader doesn't re-litigate them.

Why now

This is the prerequisite for the correctness suite. Generating expected values against the spec as it stood would have meant reading them out of pricing.js — encoding this implementation's answers to all six questions as ground truth, so the table would ratify the implementation rather than check it, and the second stack would "fail correctness" for picking a different defensible reading of an unstated rule.

Left open deliberately, recorded in docs/plan.md: whether the tier multipliers should move from spec text into rate rows discriminated by kind: 'tier', which would make them dataset-pinned and delete the unchecked duplicate constant. That costs a regeneration; the ambiguity is closed either way.

🤖 Generated with Claude Code

Ethan-Arrowood and others added 2 commits October 6, 2026 16:20
…erive

SPEC.md claims stack-neutrality, and AGENTS.md says a requirement that could
not be satisfied by Fastify + Postgres + Redis is mis-specified. Read as that
implementer, six things in the pricing path are underspecified — and one is not
underspecified but contradictory.

The contradiction first. The "POST /cart/:id/quote" section states that all
discounts draw on one budget, the
line's remaining amount, and then two paragraphs later states that cart-wide and
per-line discounts draw on separate budgets. The implementation has one:
drawDown serves all four phases and the cart-level accumulator survives only in
a stale comment. Removed the stale half of the spec and the stale comment.

The six gaps, each now stated normatively:

- Tier multipliers appeared nowhere in the spec. QUOTE-004 said tier is applied
  to pricing and never said by how much, so no second implementation could
  produce a matching unit price for any line of any quote. Now a normative table.
- BOGO stated which unit is discounted, never by how much. The implementation
  discounted the full unit price and ignored the promotion's own amount. Those
  agree only because every bogo row in the corpus carries 10000 basis points;
  they diverge the moment one doesn't. The spec now reads the magnitude from the
  promotion, and so does pricing.js — provably identical on this corpus, since
  applying 10000 basis points to an integer returns it unchanged.
- thresholdMinor gates promotions of every kind, not only the threshold kind.
  The implementation does this; the spec only mentioned it under threshold.
- The cap is a running bound, not a final truncation. Stated, with why:
  truncating afterwards would leave appliedPromotionIds citing promotions whose
  discount was scaled away, which QUOTE-012 forbids.
- A shortfall does not reduce the priced quantity. Stated, with why — pricing
  only what is in stock makes a total a function of inventory the background
  writer is continuously changing, so totals would drift under load for reasons
  unrelated to architecture.
- Shipping band boundaries. Rather than specify a fallback, a new "Rate table
  invariants" subsection under "Data model" states the
  contract the data already satisfies: contiguous, non-overlapping, inclusive
  both ends, covering every weight. Exactly one band matches, so no fallback is
  reachable, and an implementation finding none has a bad dataset.

QUOTE-010 now requires shortfall in the per-line itemization, and states the
grandTotal identity the suite already asserts.

No requirement ids added or withdrawn: these clarify existing MUSTs rather than
impose new ones. They are what makes the correctness suite legitimate — expected
values derived from a spec that determines them, rather than read out of
pricing.js and thereby ratifying the implementation instead of checking it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents and code comments pointed at "SPEC.md §4" and "§6". The reader has to
go count headers to find out what that means, and any renumbering silently
repoints every reference. They now name the header: SPEC.md, "POST /cart/:id/quote".

Where a subsection is the actual subject, the reference points there instead of
at the enclosing section — the pricing engine cites "Resolved unit price" and
"Promotion evaluation order" rather than the whole endpoint section, and the
stacking limits cite the two steps that state them.

One reference was pointing somewhere that does not exist: schemas/store.graphql
cited docs/data-model.md §foldings, and the foldings discussion is a section of
SPEC.md. Repointed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ethan-Arrowood and others added 2 commits October 7, 2026 12:18
Section references named the heading in prose: SPEC.md, "Background writes".
That is still something a reader has to go find by hand, and nothing can verify
it — which is how a reference to a #foldings section of docs/data-model.md
survived in schemas/store.graphql long after that discussion moved into SPEC.md.

References are now anchors. Code comments carry SPEC.md#background-writes;
documents carry a real markdown link. Both are clickable on GitHub, and both can
be checked.

So they are. scripts/check-spec-anchors.mjs resolves every reference against the
headings that actually exist and fails on any that does not. Renaming one
heading surfaces every pointer at it instead of orphaning them silently. It runs
in npm run check, and renaming a heading is its negative test.

SPEC.md's headings lose their numbers. They were only there to be referenced by
number, the anchors include them, and renumbering would have broken every link
the moment a section was inserted.

Checker output loses its tallies. "32 active requirements, 31 MUST" and
"18 headings, 50 references checked" are counts nobody acts on; where a check
fails, the list of failures is the information and a count of it is noise. The
one number left is the promotion probe's selectivity, which is a measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/structure.md was hard-wrapped at a column limit while every other document
in the repo runs one line per paragraph, and the sections I added to SPEC.md and
docs/data-model.md inherited the wrap. A hard wrap makes every edit reflow the
paragraph around it, so diffs show rewrapped lines that nobody changed.

Paragraphs, list items and blockquotes are now one line each. Tables, code
fences and headings are untouched, and the word content is unchanged — verified
by comparing the word multiset of each file before and after, which is also how
I caught the blockquote separator in SPEC.md that the first pass swallowed.

Unwrapping surfaced a real bug in the anchor rewrite: the links in docs/ pointed
at SPEC.md as though it were a sibling, and it is one directory up. The anchor
checker had passed them because it only resolved the fragment.

It checks the path now, and reports the path that would have been right. Both
failure modes have a negative test: rename a heading, or write a link from the
wrong directory.

The checker no longer scans itself. It names anchors in its own documentation
and error messages, and those are not references to keep valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant