Repository navigation
Pin the pricing arithmetic a second implementation cannot currently derive - #2
Open
Ethan-Arrowood wants to merge 4 commits into
Open
Ethan-Arrowood wants to merge 4 commits into
Ethan-Arrowood wants to merge 4 commits into
Conversation
…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
force-pushed
the
spec-pricing
branch
from
October 6, 2026 22:21
424f626 to
2bc991b
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Read
SPEC.mdas 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.mdsays 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:
drawDownserves 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
QUOTE-004said tier is "applied to pricing" without saying by how much. The values lived inresources/lib/pricing.js, duplicated inpackages/seed/src/vocabulary.tswith nothing checking they matchamountBasisPointsbogorow carries 10000 basis points; diverges silently the moment one doesn'tthresholdMinorgates every kind, not just thethresholdkind. The implementation does this; the spec mentioned it only under thresholdappliedPromotionIdsciting promotions whose discount was scaled away — whichQUOTE-012forbids — and makes per-line figures stop summing to the cart total0when nothing matchesOn (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.jsnow reads the BOGO magnitude from the promotion instead of assuming the full unit price. Provably behaviour-identical on this corpus: everybogorow carriesamountBasisPoints: 10000andamountMinor: 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 checkpasses, 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 intoraterows discriminated bykind: '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