Open-source distillery management for Canadian craft distillers.
Stillhouse helps a CRA-licensed spirits producer plan recipes, track production from grain through bottle, manage province-coded excise stamps, and generate the values that go on CRA Form B266 each month — all in one ledger that keeps the operational reality and the compliance reality in sync.
Each stage below has its own commit with a verified end-to-end smoke test.
| Stage | Feature |
|---|---|
| 1 | Auth + multi-tenant foundation (one tenant = one CRA spirits licence) |
| 2 | Materials + versioned recipes + projected-LAA math |
| 3 | Mash + fermentation operational capture |
| 4 | Distillation + production gauge → bulk alcohol ledger (the bridge) |
| 5 | Barrels + maturation clock + Canadian Whisky eligibility |
| 6 | Products + bottling + province-coded excise stamp lifecycle |
| 7 | Packaging removals + CRA Form B266 generation |
| 8 | Audit log for production gauge / bottling / removal / B266 submit |
| 9 | Live dashboard with LAA + duty rollups |
| 10 | Non-superuser app role so RLS actually enforces |
| 11 | Integration test that verifies tenant isolation |
| 12 | Unit tests for the load-bearing alcohol-math functions |
| 13 | Audit log extended to barrel fill / dump / regauge |
| 105 | MCP server — operate Stillhouse from Claude (phone/desktop) over Streamable HTTP |
| 106 | API token management moves into the web UI |
| 107 | MCP polish from first-pass QA: filter barrels out of bulk lists, FK→NotFound on ferment/mash log, emit empty arrays + zero numerics, prefix Connect code in MCP errors |
| 108 | MCP polish round 2: get_bulk_container rejects barrel ids, regauge can't fully drain a non-empty barrel, fermentation reading keeps real 0 measurements, slim write-tool responses |
| 109 | Bottling conserves LAA — debits source by bottleLAA rather than physical volume × source ABV, handles implicit dilution at bottling time, rejects bottling stronger than the source |
| 110 | Gin recipe backend — botanical roles, per-version sensory scores (10 axes), NGS input + maceration + distillation method, gin-aware LAA projection |
| 111 | Gin recipe UI — form branches on spirit kind, botanical-role pickers, tasting notes, sensory scoring bench (0–10 on 10 axes), version-compare with quantity & score diff highlighting |
| 112 | MCP gin-bench tools — get_recipe, list_recipe_versions, save_recipe_version_sensory; lets an LLM read the recipe + iteration history and score a fresh tasting from the still floor |
| 113 | Gin-bench QA fixes — list_recipe_versions joins sensory (compare workflow works end-to-end on MCP + web), sensory upsert is partial-update so single-axis tweaks don't null the other 9 |
| 114 | Close P3 backlog — LAA/ABV/duty/CAD rounded at display, sensory bench gated to gin recipes, FillBarrel rejects overfill, write tools emit full schema (no more _set: true with no value) |
| 115 | Sensory gin-only gate now surfaces as failed_precondition (was getting swallowed as internal error by the handler's catch-all) |
| 116 | Whisky tasting bench — SWRI Flavour Wheel axes (cereal / estery / floral / peaty / feinty / sulphury / woody / winey) + body / finish / overall; backend + MCP save_recipe_version_whisky_sensory + web UI for whisky-family recipes |
| 117 | Strength at 20 °C — gauges resolve through the CRA Canadian Alcoholometric Tables 1980; hydrometer indication + temperature determine both strength and volume, the as-observed reading is kept for audit, and every reading records which determination path produced it |
| 118 | Mash bench — cereal species on materials drives gelatinisation guidance off the real grain bill; flags when maize or rice force a separate cereal cook, checks mash temp / pH / thickness against the amylase bands, computes conversion efficiency from OG, and calculates strike temperature. MCP get_mash + plan_strike |
| 119 | Reduction calculator — proofing down by volume or by weight, with the ethanol/water volume contraction computed from the CRA density column instead of estimated. Weighing is exact (mass is additive); the volume figure carries the correction. On tank pages, the bottling page, and MCP plan_reduction |
| 120 | Sensory radar — flavour profiles drawn as shapes on both tasting benches, and two versions overlaid on one plot in version-compare. Hand-rolled SVG, no chart dependency; series colours validated for colour-blind separation against both themes |
| 121 | Angel's share — measured annual loss against the band a cool, humid warehouse should hold to, with the shelf height deciding which way the strength ought to drift. Flags a probable leak, and flags strength moving against its position as a likely gauge error. Dashboard alert + barrel panel |
| 122 | Money is not a quantity — formatCAD renders CAD at two decimals with grouping everywhere (duty payable was showing four); plus a real bug where the stagnant-inventory alert compared against the wrong enum value and silently skipped every "Other" container, and the last of the hardcoded palette names |
| 123 | Tests for the arithmetic that had none — pricing, password hashing, and the deposit/blend maths. Caught a rounding bug: the int(x+0.5) idiom in the rpc helpers rounds negatives the wrong way, and negatives reach it |
| 124 | Adopt existing stock — bring a working distillery's casks into the ledger from a scale reading and a hydrometer, with no batch history behind them. CRA's Mass/Density Procedure; keeps the cask's real age so adopted whisky doesn't lose its CW eligibility; books as opening inventory, never as production |
| 125 | Cut profile — where a run's alcohol went, as an emphasis chart with hearts against the rest, plus the mass balance (you cannot collect more than you charged) and a strength-falls-through-the-run check. Also stops a repeated fermenter charge reporting as a 500 |
| 126 | Yield sanity — a recipe's projection expressed as L/tonne against what the grain can actually give, so efficiencies left at 1.0 stop producing a confident impossible number. Separates "physically impossible" from "ahead of industry" |
| 127 | Fermentation curve — gravity and temperature over time as two plots sharing an x-axis (never a dual axis), plus phase inference and findings for a stuck ferment, thermal stress, and the pH crash that signals contamination |
| 128 | Vatting planner — what actually comes out when parcels are blended, since a blend is neither the sum of its volumes nor the weighted mean of its strengths, then optionally reduced to bottling strength. Web + MCP plan_blend |
| 129 | Pricing by channel — wholesale, on-site retail and export priced separately, with every rate carrying its provenance. Ontario and PEI now REFUSE to price the board channel rather than guessing, because neither publishes a spirits mark-up |
| 130 | Operator-supplied alcoholometric tables — the CRA tables are no longer shipped in the binary. Each install downloads its own copy (Crown material: non-commercial reproduction only), the server reads the ZIP as downloaded, and a missing copy degrades to uncorrected readings with a Settings panel explaining the one-time fix rather than taking anything down |
| 131 | Ledger integrity — QA drove the live system as a sole operator and as a team and found alcohol being created: concurrent withdrawals clobbered each other (8 fills moved 800 L while the tank fell 100), a barrel fill credited at the gauged strength but debited at the tank's, a dump silently deleted what the cask kept, and barrel/blend writes landed in already-filed B266 periods |
| 132 | Say which field and why — the day-one adoption path gets the overfill guard the fill path already had, extract_pct stops accepting 78 where 0.78 belongs (it was projecting a 1077% ABV wash), the yield check gains an absolute anchor so it can catch an extract its own ceilings are derived from, and validation errors stop arriving as internal |
| 133 | A B266 that closes — the packaged section counted alcohol drawn from the tank rather than alcohol that became bottles, so any loss at the filler pushed the reverse-walked opening balance negative on a first-ever return. Packaging loss becomes its own line, and the two sections now reconcile |
| 134 | B266 periods stop overlapping (two returns could cover the same day and report the same alcohol twice) and duty reports per rate band, so a period holding both >7% and ≤7% spirits reconciles against its own figures instead of stating one rate that doesn't multiply out. Materials take extract and moisture the way a malt spec sheet quotes them |
| 135 | Security review — the tenant export was dumping every tenant's users and CRA licence numbers (neither table is RLS-scoped), password hashes included; /mcp sat outside the interceptor chain so a viewer's token could move alcohol; seven RPCs were classified nowhere and so silently owner-only, including the strength widget every operator uses; NaN defeated every range check; and editing a product's ABV was unvalidated, which silently reclassifies its excise band |
| 136 | Committee findings — blending was counted as a B266 receipt with no matching withdrawal, so an internal move pushed the opening balance down; the bottling form told operators it would draw the bottled volume rather than the (smaller) volume actually pulled from a stronger tank; database CHECK violations all surfaced as 500s; and the mash bench answered an all-unknown-cereal bill with 0–0 °C instead of saying it doesn't know. Frontend gets its first tests |
| 137 | Dependency currency — Go 1.27, React 19, Vite 8 (Rolldown), TypeScript 7, react-router 7, and every Go and npm dependency at latest. Node 20 had been EOL since April and was pinned in three files. Codegen tool versions pinned so a developer's regeneration can't drift from what CI expects |
| 138 | Self-service signup worked only where tenant isolation wasn't enforcing — it writes its audit row before any tenant context exists, and audit_events forces row-level security, so the insert was refused and the whole signup rolled back behind a 500. Invisible in dev, which connects as the superuser |
| 139 | The B266 projection becomes testable — sixty lines of the highest-consequence arithmetic in the product took a *sqlcgen.Queries, so exercising a single line of it meant standing up Postgres and seeding a tenant, which is why both reverse-walk defects it has shipped were found by hand against a live system. Gather and project are now separate; the projection is pure and covered by nine unit tests that run against nothing |
| 140 | The two handlers that write duty onto a return get their first tests — CreateBottlingRun and CreateRemoval were invoked by no test at all, and the existing DB-backed tests seed through raw sqlc, so they covered the SQL and not the handlers. Nine tests later: removals read stock with no row lock (eight concurrent withdrawals against one lot, same lost update as stage 131), and all five document counters allocate MAX(n)+1 against a UNIQUE column with nothing serialising them — six simultaneous shipments to six different lots lost four of them behind a 500 |
| 141 | A return filed late reports the period's balances, not today's — the two closing-balance queries summed current values with no date at all, so generating May's return in August reported August's figure as May's closing stock, and because opening is reverse-walked from closing both ends moved together and the arithmetic still tied out. Internally consistent and factually wrong is the worst shape for an error |
| 142 | Excise rates become date-effective — Owed took a date and ignored it, so any amended, late or reopened prior-period return priced last year's quantities at today's rate and looked fine doing it. Rates now live in a band table keyed on the duty event date, each band citing the CRA notice it was read from, and the lookup refuses outside what it can source rather than extrapolating. A period straddling a 1 April indexation is refused too: the form has one line per rate |
| 143 | Duty crystallises at the right event — a spirits licensee without an excise warehouse licence cannot hold packaged spirits non-duty-paid at all, so duty is payable when they are packaged, not when they are sold. Stillhouse computed it in exactly one place, the removal, and so reported duty in the month of sale when CRA expects it in the month of bottling: a timing error on a filed return, carrying interest. The duty point is derived from the licence in the database, not toggled, and a cutover date means nothing already filed moves and no litre is dutied twice or dutied never |
| 144 | Instrument register — CRA requires each individual instrument used to determine volume or absolute alcohol content to be approved, and approval attaches to the serial number, not the model (EDM3-1-1 ¶24, EDM1-1-5). Stillhouse recorded how a figure was determined and nothing about what determined it, so the audit chain ran quantity → movement → determination → nothing. Gauges now name the instruments that made them; one that is named but unapproved, suspended or retired is refused, and one merely overdue for calibration warns. The tenant export gains the determination tables it was missing entirely |
| 145 | Inventory adjustments — line D on B266 page 3 is a reason-coded entry reconciling book stock to physical, and Stillhouse had no concept of one. A barrel regauge refused any upward variance outright ("regauges record losses only"), tanks could not be reconciled at all, and a downward variance was booked as evaporation whatever caused it — so a counting error and the angels' share landed on the same line, which under EDM3-4-1 do not carry the same duty treatment. An adjustment now says why, names who, keeps the book figure beside the counted one, and gets its own line in the walk rather than being absorbed into the opening balance |
| 146 | The rest of B266 page 3 — imports, receipts from other spirits licensees and licensed users, packaged spirits returned to bulk, deliveries out, denaturing to DA and SDA, exports, and bulk returned to production. Worse than missing lines: nothing in the application ever created a transfer_in_bond, transfer_out_in_bond, destruction or loss_unaccounted movement either, so the report had lines for all four that were structurally always zero. Each reportable movement now names its counterparty and its document, gauges through the instrument register, and the reason-to-wire mapping is exhaustively tested after opening_inventory was found missing from it since stage 124 |
| 147 | Losses classified by duty treatment — bulk_losses_laa was one number, and under EDM3-4-1 an approved destruction is relieved while spirits that cannot be accounted for are duty-payable. Collapsing the two gives a plausible total and the wrong duty. Three states, not two: unclassified is the honest default, because Stillhouse does not know whether a given evaporation loss is relieved and the barrel regauge that wrote it did not ask. A period holding unclassified losses reports that it isn't ready to file, and the worklist to resolve them sits on the return itself, showing what each one costs if ruled dutiable before the decision is made |
| 148 | Reporting periods stop assuming a calendar month — a fiscal month is set by notification (B268) rather than assumed, an authorized licensee may file semi-annually (B284), and the return is due by the last day of the fiscal month following the period, a date that existed nowhere in the model. The B266 page defaulted to this month: the period still running, whose figures aren't final and which nobody files. It now offers the period the licensee should actually be filing, on their own calendar, with the deadline. Also fixes a bug stage 142 introduced: refusing any period that spans an excise indexation made semi-annual filing impossible, since a January-to-June period contains 1 April by construction |
| 149 | The liability boundary — stage 104 said on screen that Stillhouse never files with CRA; what was missing was the step in between. Marking a period submitted now requires a named person to confirm, line by line, that they checked the figures, that they know Stillhouse files nothing, and that they remain responsible for the return. The wording is served by the server and stored on the period, not a flag: a tick box whose text changed in a later release proves nothing about what somebody agreed to two years ago. A TERMS.md closes the gap the AGPL leaves — it disclaims the software and says nothing about a hosting relationship |
| 150 | Backups that have actually been restored — the tenant export is data portability, not a backup, and nothing could reconstitute a running install. deploy/backup.sh verifies each dump by reading it back before counting it, checksums it on both sides of the container boundary, and refuses to leave plaintext where encryption was asked for. The restore drill restores the newest backup into a throwaway Postgres and compares row counts against the live database — and on its first run it found that a dump will not restore onto a fresh Postgres at all, because roles are cluster-wide and were not in the backup. Recovery targets, residency and the six-year retention window are written down in docs/operations.md, drill log included |
| 151 | The audit binder — everything in it already existed in pieces (period-locked snapshots, the audit log, gauge determination paths, the instruments behind them, movement-level detail) and nobody had assembled them, so answering "show me how you arrived at line 3" meant exporting four things and explaining the join by hand. One bundle per period: a print-ready document, ten CSV schedules that resolve every id to a name, and a manifest that hashes the lot. For a submitted period the figures come from the frozen snapshot, never recomputed — a binder that recalculated would print today's answer under the heading of what was filed |
| 152 | Row-level security is asserted rather than assumed. Three gaps, measured against a live schema rather than reviewed: api_tokens carried tenant_id with neither RLS enabled nor any policy — ownership was checked in Go only; the two sensory tables enabled RLS but never FORCEd it; and nothing checked any of it. The reason api_tokens was left out is real — bearer auth resolves a token hash before a tenant context exists, because that lookup is what establishes the tenant — so the table goes under the same policy as everything else and that one query goes through a keyhole: two SECURITY DEFINER functions owned by a NOLOGIN BYPASSRLS role, and nothing else. The server now refuses to boot when DATABASE_URL connects as a superuser, which is the failure that looks like everything working. A schema test enumerates every table carrying tenant_id and fails if one is missing enable, force, or a policy |
| 153 | The DB-backed tests stop bypassing the thing they are meant to prove. Every one of them connected as the superuser and drove the handlers through that same connection, so RLS never acted on them: the suite was not isolated from itself — a period one test left behind blocked writes for every other tenant, and go test ./... failed on a different test each run depending on which packages raced — and it proved nothing about tenant isolation. Fixtures now seed through the superuser pool and the code under test runs through one that RLS applies to, derived from the same DSN with SET ROLE so there is no second password to keep in step, and asserted rather than assumed. Two consequences worth naming: the two tests that actually check the tenant boundary had been skipping in every run for want of an environment variable nobody set, and a new handler-level test shows what the old configuration was hiding — under the superuser pool, tenant A reads, lists and archives tenant B's containers |
| 154 | Changing a password takes something away. ResetPassword and ChangeMyPassword updated the hash and returned — the comment in user.go said so outright — while sessions ran seven days with no idle timeout and API tokens had no expiry column at all, so an attacker who phished a password and minted a token kept both after the victim did the one thing everybody knows to do. Writing a password now sets a revocation watermark in the same statement, and any session that authenticated before it is dead; the caller's own session is re-stamped so doing the safe thing doesn't sign you out. The check sits in middleware rather than the RPC interceptor, because the audit export, tenant export and B266 binder read the session directly. Tokens get an expiry (90 days by default, never an explicit choice the UI argues with) enforced inside the auth keyhole, and a Revoke all my tokens action next to the password form. A password reset — the flow you reach for when you think you've leaked — revokes tokens without being asked. Driving the live server caught that revocation shipping broken: api_tokens is under RLS, so the sweep ran with no tenant context, matched zero rows and reported success |
| 155 | One person, two distilleries. users.email was UNIQUE across the whole install, so the outside bookkeeper or excise consultant — the person who most wants an account, and the best referral channel there is — could hold exactly one, and the second distillery to invite them got an opaque internal error. The constraint becomes UNIQUE (tenant_id, email). What that costs is that an address no longer identifies an account on its own, which matters in one place: login, which runs before a tenant context exists. It now verifies the password against every account holding the address and, when more than one matches, answers with the distilleries rather than a session — safe to name, since the caller just proved they hold all of them. Password reset issues a token per account and names the distillery in each email. tenants.cra_spirits_licence_number keeps its install-wide UNIQUE on purpose: that number is globally unique in the world, so a collision means one of the two is wrong. What was broken there was the error, not the rule |
| 156 | Releases you can name, and a rollback that has actually been run. A hosted install tracked main and there was no way to tell what was running — the operator restarting the stack and the person asking whether a fix had landed both had to reason from a container digest. Builds now carry their version through to /version, Settings → This install and the first line of the boot log, and a build that reports dev says plainly that it isn't a release. make release VERSION=… refuses a dirty tree, runs lint plus both test suites, tags, and stamps the image. The rollback story stops being a claim: TestMigrationsRoundTrip walks all 35 migrations down to nothing and back up in a throwaway database — and found on its first run that 000010's down half drops a cluster-wide role, which fails when any other database in the cluster holds grants and would have been worse if it had succeeded. Also fixes the Makefile that never worked: SHELL := /usr/bin/env bash is a path with a space in it as far as GNU Make is concerned, so every recipe needing a real shell died |
| 157 | Who the alcohol went to. Stillhouse had no customer concept: a removal named its destination in free text, so the same provincial board appeared under three spellings across three months of returns and nothing could be totalled by buyer. Worse, the destination kind — which decides whether duty is charged and which B266 line the movement lands on — was re-chosen by hand every time, next to free text that never had to agree with it. A customer record fixes the classification where the decision belongs: the LCBO is a provincial board and always will be, so a removal to them cannot be typed as an export by accident — the request's own destination_kind is ignored when a customer is named. Price lists are dated rather than mutable, and carry money as NUMERIC and as a decimal string on the wire, because rendering 34.95 through a double is how a cent goes missing. destination_name stays for the one-off and for every removal recorded before today — pointing those at customers who didn't exist when the movement happened would be inventing records |
| 158 | A second factor. Stillhouse authenticated with a password and nothing else, on a system holding the records behind a filed excise return. TOTP is written out rather than imported — the algorithm is forty lines, fully specified, and comes with published test vectors, which is a better position than "the library is popular"; internal/totp is pinned against RFC 6238 Appendix B and RFC 4226 Appendix D. Enrolment is two steps on purpose: nothing is enforced until the app has produced a matching code, because enrolling in one step means a mistyped secret locks out the person who did everything right. Shared secrets are sealed with AES-256-GCM under an operator-supplied key — a TOTP secret sitting in plaintext in a nightly backup hands over both factors at once — and with no key configured, enrolment refuses rather than storing one in the clear. The replay guard records the accepted step, so a code read over a shoulder can't be reused inside its window; ten single-use recovery codes are the way back from a lost phone |
| 159 | A role for the person who files the return. Three roles and none fit the outside bookkeeper or excise consultant: a viewer can't record the filing acknowledgement or rule on a loss's duty treatment, which is most of the engagement, so in practice they get an owner account — a real privilege escalation dressed up as convenience, and they're also the best referral channel the product has. accountant deliberately doesn't sit on the owner > operator > viewer line: more than a viewer on the compliance surface, less than an operator everywhere else. It records no gauge, no bottling, no removal — someone who both books a movement and rules on its treatment is the segregation-of-duties problem the audit trail exists to make visible. The gate gains a second dimension rather than a rank, and the procedure-coverage test now catches a typo in the allow-list, which it did on its first run |
| 160 | A dashboard nobody opens on a Tuesday is not an alert. Stillhouse could work out, at any moment, that a return was due in nine days or that there were four days of stamps left — all of it reachable only by going to look. An alert is now a condition with a life cycle, not a message: it opens when the condition becomes true, updates rather than duplicating while it stays true, and resolves itself when it stops. There is no dismiss button; acknowledging says a human has seen it, which is a different claim from the condition having gone away, and conflating the two is how alerting systems become things people mute. Five rules — return due, return overdue, stamps below a week of cover, a fermentation that stopped reporting, a cask ungauged for a year — evaluated every fifteen minutes and emailed once each, to whoever opted in and can actually act. The return-due and stamp callouts computed in the browser were deleted in the same change, because the same fact appearing twice is worse than either version alone |
| 161 | The seam that lets Stillhouse not become an accounting package. It knows what happened and what it was worth — duty crystallised on a run, grain in at a lot cost, materials into a mash, stock out — but not which account each belongs in, because that's the licensee's chart of accounts. So the mapping is data the operator supplies and an unmapped event is reported, not posted somewhere invented: a journal line in the wrong account reconciles, and then nobody looks again. Same refusal on figures: a material lot with no recorded cost produces a warning and no line, never a zero, because a zero balances perfectly while understating inventory by exactly what the lot cost. Every line carries the basis it was arrived at, in words. The two work-in-progress kinds are deliberately absent — valuing them needs labour and overhead absorption Stillhouse doesn't have (E4). The run-cost chain moved to internal/costing so the cost screen and the journal cannot give different answers |
| 162 | What the licensee actually holds. The tenant carried two free-text licence numbers and nothing else — enough to print a number on a return and enough for nothing else. Which returns exist follows from which licences are held; so does where the duty point falls; so does whether a renewal reminder is possible at all. The register carries each licence's number, effective and expiry dates, premises, and the s.23 security behind it, and existing tenants are backfilled so nothing that reads a licence number changes behaviour. Licences run two years and CRA wants the renewal 30 days out, so expiry becomes a warning at 60 days and critical at 30 — past the point where a heads-up is still a heads-up. A licence with no recorded expiry raises nothing on purpose: every CRA licence expires, so a blank means nobody entered it, and a reminder from a guessed date gets believed. The register says how many are blank instead of looking finished |
| 163 | Where every stamp went. Excise stamps are Crown-controlled and the licensee is accountable for each one; Stillhouse tracked three counters per order, which answers "how many are left" and cannot answer the question CRA actually asks — where did stamp ONT00457 go. Two things were missing. A reason: one void counter lumped together a stamp that jammed in the applicator, a roll that went missing off a bench, and a batch returned to CRA — same arithmetic, completely different events, and only one of them is reportable. And serials: the reconciliation now walks an order's issued range end to end and says of every serial which run took it, which disposition claimed it, or that it is still on hand — reporting contiguous runs, overlapping claims (two runs claiming one serial still totals correctly, which is exactly why it's dangerous), and any way the account fails to close, as sentences rather than numbers to notice. Existing voids are backfilled as spoilage, which is what that path was always for |
| 164 | CSV in. Stage 124's adopt-existing-stock path is the careful half of getting a running distillery into Stillhouse — one cask at a time, from a scale and a hydrometer, with the determination trail intact. This is the boring half: the four hundred rows somebody already has in a spreadsheet. Materials, deliveries, products, customers, casks and bottled stock, each with a column list written in the words an operator would use rather than the schema's. The dry run attempts every write and then abandons it, which is the only way to catch what validation cannot see — a perfectly well-formed file whose names collide with rows already in the database. Everything lands in one transaction, so a bad row on line 380 leaves nothing behind and there is no rollback step to remember. Casks record their strength as supplied and say so when no temperature was given, rather than implying figures went through the tables when they did not |
| 165 | What a product needs to leave the building. A SKU carried a name, a bottle size, a strength and free-text notes — enough to bottle against, not enough to sell. Now: GTIN with its check digit verified, because a bad one is a transposed pair of digits and the place that otherwise finds it is a distributor's receiving dock; board product number; case and pallet configuration; and the label content the Food and Drug Regulations and Excise Act s.87 ask for. The discipline is that Stillhouse models the fields and never invents the values — the common name is not derived from the spirit kind and the age statement is not taken from the maturation clock, because whether a spirit qualifies for a standardised common name and what a blend may claim rest on how it was made and how long it sat. Those are the licensee's declarations, and asserting either would be putting words on somebody's label. The importer takes the same columns, so a SKU list arrives complete |
| 166 | Provenance that reaches somebody. Every Rate in the pricing model has carried a Source and an AsOf since the feature was written, all seventeen jurisdictions hand-maintained — and none of it reached the API, which is the only place an operator would ever see it. Seventeen curated citations that nothing reads is the worst of the three available options. Every rate a figure leans on already passed through one function, so that function now carries the citation out with it: each channel price comes back with the rates behind it, what each one is, where it came from and when, one click from the number. The test that compares a figure's reported provenance against its weakest citation found a real inconsistency — an unrecorded on-site remittance rate marked the figure unknown without ever appearing among the rates it rested on, so the citations contradicted the label instead of explaining it |
| 167 | What the lab found, and who signed it off. A distillery measures things that never touch the B266 — methanol, congeners, water chemistry — and keeps them in a notebook beside a system that already knows which gauge, run or cask they belong to. Results now attach to exactly one of those, with the limit they were judged against, so a reader a year later doesn't have to know what "good" was; a pass or fail with no value is refused, because that's an opinion rather than a measurement. Batch release is the other half: nothing stopped a lot leaving before anybody said it could. Releasing requires saying what was checked — "approved" answers nothing when somebody asks why this lot was let out. The gate is opt-in per tenant, because a system that forces the ceremony gets the ceremony performed rather than meant — but a hold blocks regardless, since honouring a named person's decision only when a flag happens to be on would make the act meaningless. A hold doesn't erase an earlier release: that pair is a recall in its early form |
| 168 | The order behind the delivery. RecordMaterialReceipt stood alone: grain arrived, somebody typed a quantity and maybe a price, and nothing said what had been ordered, from whom, at what price, or whether the delivery matched. Suppliers, purchase orders with lines, receiving matched to a line with partial receipts, and goods-received-not-invoiced as an accrual you can point at line by line. The order's status follows its lines rather than being asserted — letting somebody declare an order received while it's short would make the outstanding view a matter of opinion. And landed cost: freight, duty and handling are absorbed into the lot rather than left in an expense account, as a generated column so it can't drift from its components, and charges arriving a week later update a lot already on the shelf — what it cost to get here didn't change, only when we learned it. The test that matters checks it reaches the accounting journal and the bottling-run cost, since a landed cost nothing reads is the K4 mistake again |
| 169 | A name that says which scale it is on. Stillhouse held two kinds of proportion and called them the same thing: abv_pct is 0–100, while extract_pct, moisture_pct and the three recipe efficiencies were fractions in [0,1] — same suffix, same product, a hundredfold apart, with MashEfficiency.Pct (a percentage) sitting beside mash_efficiency_pct (a fraction) three letters away. The range checks catch one direction only: an efficiency of 78 is obviously wrong, but an ABV of 0.40 is a legal percentage, so nothing rejects it — and that is the direction that understates duty, pricing a bottle at a hundredth of what it owes. internal/units makes Fraction and Percent distinct types with no implicit conversion, so the mix-up is a build failure rather than a wrong number; adopting them in the projection and mash-bench math turned up every place a fraction was being multiplied into a float without saying so. The columns and proto fields are renamed to _fraction, which is where the mistake actually gets made — in a query somebody writes at 11pm |
| 170 | Where, within a licensee. One tenant was one licence with no place inside it, so a distillery with a production site and a rackhouse across town had one undifferentiated pile of alcohol — a compliance limit before a convenience one, since an excise warehouse licence can name several premises and the 30% single-retail-store rule is computed per premises. Casks, packaged stock and removals now carry a location, backfilled to a default so an install that never adds a second behaves exactly as before. Two things it refuses to do. The retail supply report gives each location's share of your own removals and says plainly that this is not the rule's figure — the 30% test is against the store's whole stock, and Stillhouse cannot see what else the store bought. And the obvious AFTER INSERT trigger is absent, because signup creates a tenant with no tenant context yet and locations FORCEs RLS: making it SECURITY DEFINER would punch a hole in the tenant boundary to save typing in three call sites. A schema test asserts the invariant instead |
| 171 | What a second person needs. Stillhouse recorded what had happened and never what was going to — workable for an owner-operator holding the plan in their head, and the thing that stops working the day somebody else walks in, because what should I be doing had no answer in the system. A work order is deliberately thin: an intention with a subject, an owner and a date, pointing at what it produced rather than holding its own copy of the numbers. The temptation is to have it carry quantities and reconcile them against the ledger, and that way there are two sources of truth for one batch and the trusted one is whichever was typed into last. Unassigned is a real state — planning a week is mostly unassigned jobs on a board. Timestamps are stamped by the transition rather than typed afterwards, and a finished order can't be edited: editing a plan and editing a record are different acts. The alert fires on a missed due date, never on a job merely scheduled in the past — a system that shouts about a job done a day late is one people mute, and the same channel carries the return deadline |
| 172 | What went back into the still, and what came out. Stage 146 gave page 3 the reportable movement, so the B266 line has been fillable since — what never existed is the record either side of it. That gap matters because a redistillation is the one operation where alcohol legitimately disappears in bulk and nobody is obliged to notice: spirit leaves stock as a withdrawal, weeks later a run produces less than went in, and with nothing joining the two the difference isn't a loss anyone classified, it's just a number that got smaller. The withdrawal and the record are written in one transaction so they can't disagree; the loss is a generated column and stays NULL while the run is unfinished rather than showing the whole charge as lost; more out than in is refused, because a still doesn't create alcohol and the usual cause is litres typed where LAA was wanted. Writing the test found that Kinds — what the resolver is scoped to — had silently missed three stages of new rules, so licence renewals, overdue work and open redistillations would never have closed. A parser-based guard now fails when a rule is added without it |
| 173 | The shipment writes the removal. Sales was the largest hole left: no order, no pick, and — the part that shows up on a return — a removal recorded as a second, unrelated act by whoever remembered. A pallet that went out without somebody typing one afterwards was a silent under-report, and a removal typed against the wrong lot moved duty between jurisdictions with nothing to catch it. Marking a shipment shipped now writes the removals inside the same transaction, against the lots that were actually picked; recordRemoval is extracted so the two paths are not merely similar but the same code — the same period lock, release gate, duty decision, stock decrement and audit entry — and a test asserts a shipped removal charges what a hand-recorded one charges. Reservation stays deliberately soft: an order does not decrement stock, because the alcohol has not moved and a return built on promises rather than movements would be wrong, so a What's spoken for screen carries the promise instead. Picking counts other open shipments, or two pickers promise the same bottles; a lot held between picking and shipping still cannot go. Order status follows the stock rather than being claimed by hand, and the packing slip that travels with the pallet names both licences |
| 174 | The export that quietly wasn't. /export/tenant.zip is what Stillhouse offers for s.206 retention and for the operator who wants their data out — and three of the names in its table list were tables that have never existed: barrels, mash_ingredients, material_receipts. dumpTableToZip logs a warning and continues when a table is missing, so the zip went out looking complete while carrying no barrel maturation record, no mash bill, and no record of any material ever received. Nothing failed; the operator got a zip, the log got a line nobody reads, and the omission would surface only when somebody went looking for a record that wasn't there. Nineteen more tenant-owned tables added by later stages — customers, price lists, locations, licences, lab results, purchasing, work orders, redistillations, stamp dispositions, journal mappings — had simply never been added. The fix is not the corrected list; it is that the list stops being something to remember. A schema-derived test enumerates every table carrying tenant_id and fails unless each is either exported or exempted with a stated reason, and fails again on any name in the list that is not a real table. api_tokens is exempted by the same argument that keeps password_hash out of users.csv |
| 175 | Figures that are what they say they are. One 700 mL bottle at 46.5 % is 0.3255 LAA; bottles × mL ÷ 1000 × abv ÷ 100 lands one ulp low and the API said 0.32549999999999996. Every removal, gauge and bottling run goes through arithmetic of that shape, and a duty total summed across lines came out 0.8399999999999999 — QA finding F17. The browser rounded at display, so the noise was invisible in the one place somebody would have questioned it and fully present in every other: the MCP tools, curl against the endpoints, the CSV a licensee builds their own spreadsheet from. A figure on an excise return that reads 0.8399999999999999 does not inspire confidence in the rest of it. Fixed with one rule in one place rather than a table of per-field precisions across a hundred and forty proto fields — a table has to be kept in step and is wrong the first time somebody forgets, and every coarser precision it could encode is a decision about display. Six decimal places sits below anything a distillery measures (the finest is a volume correction factor at five) and above every artefact of the arithmetic. A ConnectRPC interceptor covers the API; MCP calls the service implementations directly and never sees an interceptor, so its two result helpers call the same code. Nothing stored or computed changes — server-side sums are still exact |
| 176 | Whose spirits, and whether you hold them. EDM10-1-7 page 3 asks for all bulk spirits in your possession regardless of who owns them, and none that you own but do not hold. Neither half was expressible: a container was implicitly yours and implicitly here, so a contract distiller's return was wrong both ways — and the reason is worse than the arithmetic, because an operator storing a customer's casks has no way to record them, so they don't, and the return under-reports spirits the licensee is answerable for. Ownership and possession are deliberately two facts, not one: ownership decides whether the alcohol is an asset and has nothing to do with the B266; possession decides the B266 and nothing else. The hard part is that the closing balance walks backwards from container state through movements, and a change of possession is a state transition that produces no movement — so a bare flag would silently restate a filed period, which a falsification run confirms (600 LAA becomes 200 for a return already signed). Instead the transition writes an in-bond transfer for the whole balance, which is what it actually is and which the return already has a line for, and nothing may be recorded against spirits held elsewhere — you cannot gauge a cask in somebody else's warehouse. Given those two rules the walk is correct with no change to its movement side. Cost of sales still values every removal as if the goods were yours; packaged ownership is not modelled, so the journal says so rather than guessing |
| 177 | Barcodes for a rackhouse. Finding cask 0417 by scrolling a list is not how a rackhouse is operated; a tag on the bung stave and a wedge scanner is. The code is derived from the row's id, not stored — a stored one needs a column, a uniqueness constraint, a generator at every insert site and a backfill, four things to keep right, and buys nothing when the id is already unique and immutable. Fourteen characters: a kind letter and 13 Crockford base-32 digits carrying the first 64 bits, so O reads as zero and I as one and a code smudged on a thermal label still resolves. The kind letter means a scan says what it addresses before anything is looked up, so a case label at the pick screen is refused with "that is a cask, not a packaged lot" rather than wandering off to another page — the refusal is the feature. Truncation is safe by a wide margin but not by definition, so two rows sharing a prefix is reported as an ambiguity rather than resolved by coin toss. Code 128 is written out rather than pulled in: it is a lookup table and two loops, on a page with no CDN, and the standard fixes enough about every symbol's shape (six runs, 11 modules, an even number of bar modules, all distinct) that a test catches a mistyped digit — which it did, finding a doubled terminating bar that would have rendered as one wide bar and decoded as neither. A scanner is a keyboard, so scanning is an input and a submit: press / anywhere |
| 178 | What a batch cost, beyond the grain. BottlingRunCost was direct materials only and said so honestly on every line — but a distillery whose cost of sales is the price of barley believes its whisky costs eight dollars a bottle, and prices it accordingly. Labour, overhead absorption and a WIP convention are what migration 000040 said were needed before the two work-in-progress journal kinds could be emitted; this is those three. Every rate is the licensee's own policy and nothing has a default: an unset rate makes its component unavailable and says which, rather than absorbing zero and reporting a partial cost as a full one — and zero hours recorded is treated as no record of any, not as no labour. Overhead absorbs on one of three stated bases (per material dollar, per labour hour, per LAA) because there is no correct answer, only a stated one. Rates are effective-dated for the reason possession was in 176: a bare column would restate every batch ever costed, including those an accountant has already taken into a set of books — asserted by costing an August run, then adding a September rate, and checking it did not move. Inventory value covers WIP (casks included — the largest a whisky distillery has) and finished goods, and names every line it could not price, because a valuation that quietly omits those reads as a smaller inventory rather than an incomplete one. wip_production is deliberately still unemitted: apportioning a mash across the gauges it fed is a convention Stillhouse does not have, and adding an enum value nothing writes would be the first half of inventing one |
| 179 | Provincial reporting: the shape, before the content. Every province a licensee sells into wants something reported and no two want the same thing on the same clock — and the failure mode here is worse than a federal one, because a wrong excise figure is caught by CRA and a wrong provincial deadline is caught by a delisting. So Stillhouse ships the machinery and refuses to ship the content: a requirement is recorded by the licensee or their consultant and carries the citation it came from, the same discipline the pricing rates follow, and claiming a requirement is sourced without naming the source is refused. A definition where nobody recorded how many days after period end the report is owed produces periods with no due date, which can never go overdue — inventing the deadline would be worse than having none, because it would look like one. Periods align to the calendar or to the licensee's own fiscal month, because a province wanting calendar months while the licensee files excise on a month ending on the 25th is the ordinary case, and quietly assuming they agree is how a period gets reported twice; consecutive periods are asserted to abut exactly across every cadence and both calendars. The figures follow the buyer, not the stamps — a case stamped for one province and sold into another belongs to the second, and reporting by stamp would credit Ontario with a shipment that went to Alberta. Removals naming no customer are reported alongside rather than dropped |
| 180 | Getting paid. An order and a shipment do not ask anybody for money; an invoice is the document a customer pays against and the record of whether they have. It bills from what actually shipped, at the price agreed on the order line each pick satisfied — a pick with no order line behind it has no agreed price, so it comes back priced at zero and says so rather than quietly billing nothing. The arithmetic goes through internal/money, exact decimal over big.Rat: sixty bottles at $34.95 is $2,097.00 and thirteen percent of that is $272.61, and 1.005 rounds up because it is one and a half cents rather than the 1.00499999999999989 a float64 holds. Tax rates are the licensee's, effective-dated, with provenance — and a rate typed as 13 instead of 0.13 is refused, because the mistake is easy and multiplies every invoice by fourteen. Tax resolves at issue, not at line entry, so a draft sitting across a rate change carries the new rate; once issued it is frozen on the line, and a credit note credits at the rate that was charged rather than today's. An issued invoice cannot be edited and a paid one cannot be voided — money already received cannot be made not to have arrived, so it gets a credit note. Ageing bands run from the due date: an invoice on 60-day terms issued 45 days ago is not overdue, and a report that says it is trains people to ignore the report |
| 181 | Seven findings from driving a fresh install end to end, and the fixes. The one that matters: a bottling run with no material chain behind it — adopted opening stock — reported its cost as complete, because "no unpriced lines" was trivially true of no lines at all, and the journal described the figure as "direct materials, labour and absorbed overhead" while containing no materials. That is the failure stage 178 was written to prevent, arrived at from the other side; zero traced materials is not zero materials, the same distinction the labour side already made about hours. Also: a duplicate container name answered internal error with a 500 when the constraint knew exactly what was wrong; a cask both customer-owned and held elsewhere fell between the two ownership figures, so the totals read zero above a row that plainly was not, and there are now three that partition everything; ProvincialSalesLine.jurisdiction was set nowhere, which a build is perfectly happy with; and plural returns "es", which is right for loss and gave "destructiones" on a B266 filing blocker. The API also silently discarded unknown JSON fields — {"gtin": …} to CreateProduct returned 200 with the value dropped — which for a supported surface whose numbers reach a filed return is worse than failing. Finally, a pre-existing test read its own expectation through the admin pool and so summed every tenant; it passed only because the database happened to be empty |
| 182 | Marked special containers. EDM3-8-1: a container of 100 to 1,500 litres, marked rather than stamped, for delivery to a registered user or to bottle-your-own premises. Stage 143 split the packaging figures by duty treatment and left the third column of that line — packaged in marked special containers — with nothing that could write it. Modelled as their own thing rather than as packaged inventory: a lot of bottles is fungible and counted, a marked container is one object with one mark on it and a way back to bulk that bottles do not have. Filling draws from bulk under transfer_to_packaging exactly as a bottling run does, and duty crystallises at the licensee's own duty point through the same code, so the two can never disagree about when. No excise stamp is drawn — these are marked, which is the distinction, and a licensee whose circumstances differ sees that none was consumed rather than discovering a silent one. s.156 unmarking is a movement in the ledger and not a correction: the alcohol really did go back, so the destination receives it under packaged_returned_to_bulk at a volume-weighted strength, and the container drops out of the packaging figure rather than being deleted from it — a return that says stock was packaged and nothing about its coming back is wrong in the direction that over-reports. What the mark must say is EDM3-8-1's to specify; Stillhouse records what was applied and generates nothing |
| 183 | Whose bottles these are. Stage 176 gave bulk containers an owner and left packaged stock without one, so the chain from a removal back to whoever owned the spirits stopped at the bottling run: cost of sales valued every removal as if the goods were the licensee's, and the journal attached a warning saying so — a known-wrong figure with a note on it, which is better than a silent one and worse than a right one. The item was raised assuming ownership had to be effective-dated, since a cask sold in place last quarter would otherwise restate a closed period. It does not: the owner is copied onto the lot at the moment it is packaged, the way bill_to_name is copied onto an invoice at issue and destination_name onto a removal at the event — a document already produced does not change when the underlying record does, and the test sells the cask afterwards to prove the lot does not move. So a contract-packaged removal now posts no cost of sales at all: nothing was sold, the revenue is a service fee, and the journal says which removals it left out rather than quietly differing from the return. Those bottles also drop out of the inventory valuation, because a customer's stock is on your B266 and not on your balance sheet. Nothing is backfilled — every lot packaged before this was the licensee's, and NULL says exactly that |
| 184 | The stills, and everything else with a capacity or a service interval. Bulk containers were already first-class, but they are vessels alcohol goes into; what never existed is the plant a run is performed on — the still, the tun, the filler, the pumps. Without it a distillation run named no still, capacity was a number in somebody's head, and maintenance was a reminder on a phone. Deliberately not modelled as bulk containers with a flag: a still is not a vessel holding a balance, and putting one in that table would mean a row that must never appear in a LAA sum — the kind of exception that gets forgotten exactly once. The rule running through it is that an unrecorded figure is absent, not zero: a capacity of zero is refused with a sentence telling you to leave it blank, an item with no recorded service interval is never due (a schedule Stillhouse invented is one nobody agreed to), and the register counts how many items have no capacity because scheduling cannot use them. Run durations come from work orders that recorded a start and a finish, reported as the median — one run left going over a weekend should not move an estimate a scheduler builds on. F3 can now say something honest about capacity; what it still lacks is the forecast |
| 185 | What needs making, and whether the plant can make it. Built from actual demand — confirmed, unshipped order lines, real commitments to real customers — rather than from a forecast, because Stillhouse has none and a plan built on an invented one looks exactly as authoritative as a plan built on orders. The basis is stated in the response and printed on the page every time rather than left to be assumed. Supply is bottles you own, here, released, and not already picked onto an open shipment, so two orders competing for the same stock do not both look satisfiable. The shortfall is converted to LAA and set against the bulk you actually hold — ours, here, not in somebody else's cask — which is the one number worth seeing before promising a date. The plant half follows the same rule: equipment with no recorded capacity, or with no timed run and no recorded run time, cannot be planned against and is returned with the reason rather than dropped, because an empty schedule and a schedule that silently omitted half the still house look identical from the outside. Durations prefer what was observed over what was typed. An empty plan says nothing has been ordered, which is not the same statement as nothing needs making |
| 186 | Counting the warehouse, and what to do about the difference. Stage 149 gave bulk containers a reason-coded adjustment, but one container at a time from that container's own page; counting a warehouse is a sheet, worked through in order, with the book figure beside a blank. The book figures are captured when the count opens rather than when a line posts — a count that takes a morning while somebody else is shipping would otherwise measure the shipping. Packaged stock had no adjustment path at all, and editing bottles_on_hand directly would have been worse than none: the B266's packaged balance is walked backwards by undoing runs and removals, so a balance that changed with nothing in the ledger to undo would silently restate a period already filed — the same failure a possession flag with no movement would have caused. So an adjustment is a row the walk can undo, SumPackagedOnHandAsOf learns about it in the same migration, and a test asserts a balance thirty days old does not move when a count posts. A variance with no reason is refused. Lines that cannot be posted are named — a count that quietly posted half of itself is worse than one that posted none — and vessels are deliberately sent back to their own page, because their adjustment is a gauge with instruments and a temperature correction and a second implementation of that arithmetic would decide a B266 line twice. Also adds a guard that failed three times this session unnoticed: a report field never assigned in the projection now fails a test instead of shipping as zero |
| 187 | Knowing before the glass runs out. The excise stamp panel already answered this for stamps — bottles a day over the last thirty, divided into what is left — and what it computes is not special to stamps: it is cover, and every material has it. Consumption is what actually went into mashes over a window the reader chooses, so the rate is observed rather than assumed. Two figures are the licensee's and neither has a default: a reorder point Stillhouse guessed would fire at a level nobody chose, and an alert people did not ask for is one they learn to dismiss — which costs more than having none. A material with nothing consumed reports its cover as unknown rather than infinite, because one nobody has used yet may be about to be used daily. What is already on a purchase order counts toward the reorder point, so an alert does not fire on grain that is on a truck; and where a lead time is recorded, cover shorter than it is flagged, because ordering then is already late |
| 188 | What is owed, beside what is posted. The renewal reminders shipped in stage 162; what was left was the part that is not a reminder — whether the s.23 security actually covers the exposure, which needs a liability at a point in time rather than a date. Three parts, each a figure Stillhouse already computes: duty on submitted returns (reported as owing and labelled, because Stillhouse cannot see a payment to CRA), duty crystallised in periods with no submitted return, and duty that has not crystallised but would, on packaged stock still held that was not dutied at packaging. Deliberately not a verdict: what security s.23 requires is CRA's determination and turns on things outside this system, so printing a pass or a fail would be inventing a threshold — the same mistake as inventing a rate. It sets the two figures side by side and stops. Where today's duty rate cannot be sourced the contingent LAA is still reported and the money is not, so the total is visibly short rather than quietly so |
| 189 | The evidence behind a certificate of age and origin. EDM3-1-1 ¶43–46: a certificate is signed by a Canadian official, so Stillhouse does not produce one — it assembles what its own records support and says outright that it is not certifying anything. Age is each cask's days in wood as recorded when it was emptied, not its age today, and the claim is the youngest cask in the run, because a blend is only as old as its youngest part. Three things stop a claim and each names the cask rather than being absorbed: an age never recorded at dump, a vessel over 700 L which is not small wood, and spirits put back through the still — age resets on redistillation. Where any cask is unaccounted for, the age is not offered at all, only mentioned as what the accounted-for casks would have supported. A packet that quietly rounded up over a gap in the record is the single failure that matters here, and the test asserts a tank never filled from wood claims nothing |
| 190 | Records retention, as a commitment rather than an intention. Subsection 206(1) wants records sufficient to determine compliance; Stillhouse already deletes almost nothing — movements, gauges, runs and removals are append-only or void-and-keep — but almost nothing is not a policy, and a licensee asked what their retention is could not answer from the code. The window and the backup story are the licensee's words, not Stillhouse's: an unstated window reads as unstated rather than defaulting to six years, because a stated policy nobody stated is not one. Editing the window does not re-date the review, since re-dating a review that did not happen makes the date meaningless. A legal hold is a named, dated instruction that nothing be removed, and it is only a hold if it stops something — assertNoLegalHold sits in the five paths that really delete a row, in one place, so a delete added later cannot quietly escape it. Voids are deliberately exempt: voiding is how Stillhouse reverses nearly everything, it keeps the row and the reason, and a hold that stopped operators correcting mistakes would be lifted within a week. The coverage table reports the oldest record in each class it would be asked for, with nothing held distinguished from oldest is this morning |
| 191 | Whether the return continues the last one filed. Every other figure on a B266 is derived from the same ledger it would be checked against, and the opening balance is reverse-walked from closing — so the return balances against itself no matter what is missing from it, and a movement nobody recorded is quietly absorbed into opening rather than showing up as a discrepancy. The one number that is not derived from the current ledger is the prior return's closing balance: it is already with CRA and does not move when the ledger moves underneath it. Comparing the two is the only independent check the return has. A break names the difference and then names the entries most likely to have caused it — rows dated inside the filed period but entered after it was filed, which is the common case and the fixable one — and says outright when a late entry does not explain the gap, because that is a different problem with a different fix. The net effect is computed with the same expression that defines the closing balance (SumBulkOnHandAsOf), not a second classification that could drift from it. Drafts are never compared against: a draft is recomputed on every generation, so its closing balance tracks the ledger and the check could not fail. Not checked stays distinguishable from checked and clean on the wire, since telling a first-time filer their books tie out when nothing looked would be worse than saying nothing |
| 192 | review_filing over MCP — the filing question asked at the still rather than on the returns page. Answers can I file yet, and if not what first? by ordering the outstanding work: losses awaiting a duty treatment come before generating, because generating first produces a return that has to be regenerated; the return's own blockers follow; the continuity break from stage 191 comes last of the substantive steps, because it concerns a return already filed and may need an amendment rather than an edit. Reads only — B266 generation writes a draft period and stays in the web UI, and a tool that created periods as a side effect of being asked a question would be the wrong shape however convenient. That is enforced rather than intended: a test bounds the handler's source and fails if any procedure it reaches is one a viewer may not call. Each of the four procedures is authorised on its own name instead of under one guard for the first — they happen to require the same role today, and a tool that assumed that would stop checking the moment somebody raised one. Nothing outstanding is deliberately not a green light: it means nothing is missing, not that the figures are right |
| 193 | Spirit gauged into work in progress, valued. Its twin — the transfer out of WIP at bottling — shipped in stage 178; this side had sat open since 000040 on the grounds that apportioning a mash that fed several gauges is a convention Stillhouse does not have. That was right about the convention and wrong about the conclusion: everywhere else it must not guess, Stillhouse makes the licensee state the rule and refuses by name until they do — the chart of accounts, the retention window, a rate it cannot cite — and WIP had been treated as a special case. The convention is also narrower than assumed, because most of the walk already has a recorded basis: a mash that fed one fermentation needs no split, distillation_charges records what was charged, and production_gauges is UNIQUE per run. Exactly one choice is left, and it is one an accountant would want stated — whether a fermentation's cost follows the litres charged to a still or the alcohol charged, which differ for a low-wines run and a spirit run drawing the same volume. Unset refuses. A mash with an unpriced material line makes its gauges unvalued, never cheap; wash never charged leaves its cost behind, because wash that reached no still is a loss, not work in progress; and a voided run carries nothing, since its spirit left the ledger. The apportionment is asserted to sum back to what the mash cost — apportionment that doesn't conserve isn't apportionment |
| 194 | Recall simulation — the trace run forwards. TraceBottlingRun walks back from a bottle to what made it; a recall needs the other direction, from a material lot known to be bad to everything that might carry it and everyone who received it (SFCR one-up-one-down). The design turns on one boundary. Up to the production gauge the chain is exact — recorded links from lot to mash to fermentation to charge to gauge, nothing inferred. Past it, spirit is blended, vatted and transferred, and which mash is in this tank stops being a fact the ledger holds. The two sets are reported apart and never summed, because both errors are expensive and point opposite ways: treat possible contact as certainty and you destroy good stock, ignore it and you leave bad stock on a shelf. Each container is searched from its own arrival date — a run bottled before the spirit arrived cannot contain it — and a voided distillation widens nothing, since its spirit went back out of the ledger, though the gauge is still listed and flagged because distilled then reversed is part of the answer. On-hand is split from removed: one can be held, the other has to be chased. It simulates and does not act — nothing held, blocked or notified. Both bounds were verified by removing them and watching a named test fail |
| 195 | Switching distillery without signing out. Stage 155 made one email able to hold an account at two distilleries; moving between them still meant a full sign-out. The interesting part is what the switch is not: it is not a context change, it is a re-authentication. Login verifies the password against each candidate account separately — one password may be right at one distillery and wrong at another — so a session at one proves nothing whatsoever about another, and a switcher that skipped the check would be an authentication bypass wearing the costume of a convenience feature. So the password is checked against the target, the target's second factor is required whether or not the current session needed one (otherwise switching is a route into an MFA-protected account without its MFA), the session token is renewed exactly as login does, and the role that comes across is the one held at the destination — carrying the origin's would hand an owner's reach to a viewer account. A distillery the address holds no account at answers identically to a wrong password, so an authenticated session is not an oracle for where an address is registered. Each of those four is pinned by a test that was written by removing the check and watching it fail. What is saved is signing out; not proving who you are |
| 196 | Outbound webhooks. Mostly a study in not building an SSRF primitive: an operator-supplied URL the server then fetches is one by default. Validation at registration is the cheap half and explicitly not the protection — DNS resolves at delivery time, so a name can point somewhere public for the check and at 169.254.169.254 for the send. What actually protects the server is a dialler Control hook that judges every resolved address on every attempt, plus a refusal to follow redirects, since a redirect is a second URL nobody registered. Deliveries are an outbox written in the same transaction as the event: a filing that rolls back cannot leave a webhook saying it happened, and dispatch-after-commit has a window where the process dies and the event is silently never sent — neither failure is visible to the receiver, which is what makes them worth the care. Signed with HMAC-SHA256 over timestamp-and-body, timestamp inside the MAC so a captured delivery cannot be replayed with a fresh one; the secret is shown once and sealed, because a secret an API reads back leaks through every log and screenshot that touches it. The worker reaches across tenants through the same NOLOGIN/BYPASSRLS keyhole stage 33 built for bearer auth, rather than throwing away RLS for one goroutine |
| 197 | The cask owner's statement. Cask-ownership programmes are the case: the cask is in our possession and is somebody else's property, and they are entitled to know what has happened to it. Wood, fill gauge, today's gauge, every gauge in between, the angel's share since fill, and what duty would fall due if it were bottled today. What makes this the strictest screen in the app is where it goes — it leaves the building, gets kept, and is read by somebody with no way to check it, so a plausible-looking invented figure would never be caught. A fill gauge that recorded no strength therefore refuses the loss rather than subtracting today's LAA from a number nobody wrote down; a cask with no fill gauge says that instead, because it is a different gap with a different fix; a cask filled this week refuses an annual rate; and a duty figure appears only where the rate table can cite one for today. Voided gauges are excluded — a measurement the distillery has already withdrawn must not appear on a customer's document. And the statement says on its face that it is not a certificate of age or origin (EDM3-1-1 ¶43–46, see stage 189): those are signed by a Canadian official, and this is a statement of our own records, said to be that |
| 198 | Product coming back from the duty-paid market. Spirits have no consignment regime the way small wine licensees do, but returns happen — damaged cases, delistings, over-ordering — and the whole judgement here is what a return must not do. Duty crystallised when the goods were packaged or removed and does not un-crystallise because they came back; recovering it is a refund claim under s.181/s.182 with a B256 behind it, which is A9 and blocked on sourcing that form. So a return restocks, credits the customer, records the duty that was paid so the claim can be evidenced rather than asserted, and says outright on every response and above the form that the duty is unchanged — because the operator recording it is exactly who would otherwise assume otherwise, and a filed return will not tell them. Saleable restocks; unsaleable does not — putting stock that cannot be sold into a figure saying it can is the same error in miniature. Bottles are capped at what the lot has ever had removed, and bottles_removed is reduced in step so a returned bottle is never both removed and on hand. The packaged reverse-walk gained a returned_after CTE: without it, stock coming back in August silently restated an April return already filed |
| 199 | The keg register — the returnable asset, not its contents. A keg's spirits are already recorded elsewhere and reach the B266 from there, so the register carries no alcohol figures at all; the obvious design, volume_l and abv_pct on the keg, puts the same LAA on a filed return twice, and a test asserts the return does not move when a keg is filled. Which row the contents live in follows from the keg's size, and the threshold is the Act's: a marked special container under EDM3-8-1 is 100–1500 L, so spirits in anything smaller are packaged, exactly as a bottle is — a 50 L keg is a large bottle as far as the Act is concerned. Enforced in Go and again as a table CHECK, because a 50 L keg holding a marked container would count the same alcohol as both. The cycle is a transition table rather than a chain of ifs, since the illegal moves are the point: filling a full keg loses the first fill's spirits, shipping one already out means two customers hold the same asset, and a keg nobody has cleaned is not fillable — which is the whole reason returned, dirty is a status of its own. Deposits net to nothing over a round trip; a lost keg keeps its deposit outstanding, because that is what a deposit is for |
| 200 | Tasting-room and web sales, arriving as data rather than as typing. PLAN G4 calls this a compliance feature wearing a sales costume — every sale keyed by hand is a chance to under-report — and automating it introduces the opposite failure, which is what the design is shaped around. A POS webhook is delivered at least once; every one of them retries. A retry that created a second removal would report duty twice and take stock off the shelf that is still on it: under-reporting is a penalty, over-reporting is a penalty and a stock figure nobody can reconcile. So ingest is idempotent on the till's own line id, and redelivery is counted rather than treated as an error. The other half is the SKU: Stillhouse cannot know what GIN-750-DRY is, and posting against the wrong product is wrong duty and wrong stock on a filed return — so an unmapped SKU is rejected and kept, never dropped and never guessed, because a sale that vanishes for want of a mapping is the very under-reporting this feature exists to prevent, arriving through the door it opened. One bad SKU does not stop the rest of the day's takings. Posting reuses recordRemoval — the seam stage 173 extracted so hand-keyed and automatic removals cannot drift; this is its third caller — and carries no stock check of its own, because stock is enforced atomically at the write and a second copy of that rule would be readable a moment before it stopped being true |
| 201 | Demand forecasting, kept visibly apart from demand. Stage 185 built the production plan from actual orders and says so on the page every time, because a plan built on an invented forecast looks exactly as authoritative as one built on orders — which is the entire constraint here. So a forecast is reported beside the committed figure and never added to it: one number combining twelve bottles somebody has paid for with forty somebody might buy cannot be taken apart again. The method is the licensee's, unset refuses, and the two computed methods are chosen precisely because they disagree — a trailing average and a same-month-last-year differ by an order of magnitude on seasonal sales, and which is right turns on something Stillhouse cannot see. A test asserts they disagree, since a setting that changes nothing is not a choice. Refusals throughout: no history refuses rather than projecting zero, because a product nobody has sold and one forecast to sell nothing are different claims and planning on the second under-produces invisibly; a first year has no same-month-last-year by definition. A dry month inside the averaging window counts as the zero it is — dropping it reads high and would have somebody produce for demand that is not there. Hand-entered figures beat computed ones and require a reason, because somebody typed 400 is not a basis anybody can check next quarter |
| 202 | Anonymised cross-tenant benchmarks. This is the only read in Stillhouse that crosses the tenant boundary, which makes it the one place where getting the privacy wrong publishes a distillery's operations to its competitors — so it is four rules and a refusal, never a bend. Opt-in, off by default and never inferred, because consent that has to be withdrawn is not consent. Reciprocity: only a participant may read, since a reader who contributes nothing is a data tap rather than a network. A k-anonymity floor counted in distinct licensees, not measurements — one distillery with four hundred casks is still one distillery, and a cohort of its casks is its own figure with a label on it. And dominance suppression, because even at five participants a cohort where one supplies most of the sample is that one with a label on it. What comes back is quartiles and never extremes: a maximum is somebody's exact number, and publishing it is publishing them. A refusal carries no quartiles and no counts, or it is a refusal in name only. Cross-tenant reads go through the same NOLOGIN/BYPASSRLS keyhole as stages 33 and 196, with the opt-in filter inside the SECURITY DEFINER function where the app role cannot reach around it |
| 203 | A journal file QuickBooks Online and Xero will actually import. G2 said the hard part was already done — stage 161's account mapping and event set made this a transport rather than a design — and it was right; what was missing was the shape. /export/journal.csv is for a person: both accounts on one row, warnings as comment rows above the header. An accounting package wants the opposite — one row per side tied by an entry number, and a # line is a parse error. The real difference is what each may do when something is unmapped. The human file prints its warnings and lets the reader judge; an import file cannot, because it goes into somebody's books and a row with a blank account either fails the import (the good case) or lands in a suspense account nobody looks at again. Half a journal reconciles to within the missing half, which is exactly why nobody notices — so this refuses with 409 and names the unmapped kinds. The amount is rounded to cents once and the same string written to both sides, since formatting each independently is how a balanced journal becomes an unbalanced file. And the total is asserted before a byte is written: debit and credit are equal per line by construction, so that check should be unreachable, which is the reason to make it — an arithmetic slip would otherwise be found by an accountant rather than by us |
| 204 | What a forecast means for the still and the grain store. Stage 201 projected demand; this turns it into bottles to make, the alcohol they need, and the materials to make it. Two halves that fail independently, because their reasons differ: the alcohol figure is exact arithmetic on a product's own size and strength, while the grain needs a recipe somebody has linked — reporting one availability would hide a usable answer behind a missing one. The link is new (products.recipe_version_id), nullable, and refuses rather than inferring: guessing the recipe from the last run that made this product would be right most of the time and wrong exactly when a distillery has changed one, which is when somebody is most likely to be planning. Grain scales the recipe linearly, and the batch count is reported so the assumption is visible — 2.4 batches is three mashes, and no arithmetic here can decide that. The projection runs through internal/distilling, the same code the recipe page shows, so the grain a plan asks for and the alcohol a recipe promises are one arithmetic rather than two. Free alcohol and maturing alcohol stay apart: adding a cask that needs three more years to the available figure would say a shortfall is covered when it is not |
| 205 | When to mash and when to order. Stage 204 reported a batch count as a decimal deliberately, because rounding it is a fact about the plant rather than the arithmetic: 2.4 batches is three mashes on one tun and two on a larger one, and Stillhouse had an equipment register with capacities in it and no way to say which vessel a recipe uses. That link is now on the recipe, nullable, and an unstated vessel refuses — picking the largest tun would be right at a distillery with one and wrong at any that has reason to own two. The order-by date counts back from the start of the period by the longest lead time on the bill, because an order arrives when its slowest line does; and a bill where some lines have no lead time recorded refuses rather than giving a date that is only right for the rest, since a date somebody trusts is worse than none. The two halves refuse independently, and their reasons live in different places — the mash count wants a vessel on the recipe, the order date wants lead times on the materials — so an operator who has done one and not the other sees the half they have. materials.lead_time_days had existed since the purchasing work with nothing reading it |
| 206 | The job board on the MCP surface — list_work_orders and set_work_order_status, closing PLAN J4's work-order flow. The line this draws is about the shape of the input, not the risk. Raising a job is a title, a kind, an assignee, a date and a location: five fields a form asks for at once and a chat asks one at a time, badly — so SaveWorkOrder stays in the web UI, and a test asserts the call is absent rather than the word, since this file's own comments explain why it is. Moving one along is a single field, and it is the half done with wet hands: started this, finished that. An unrecognised status is an error, not a default — a model saying paused must be told, because quietly reading it as planned moves a finished job backwards on somebody's board. Cancelling takes a reason, since a cancelled job without one reads next month exactly like one nobody got to. The role gate sees the two halves correctly: a viewer reads the board and cannot move a job |
What's next: PLAN.md is the open backlog — the excise
correctness work, the returns beyond B266, and the operational surface
still missing against the commercial alternatives. This table records what
shipped; that file records what hasn't.
v1 milestone: file one real B266 from Stillhouse for a production month. Achieved at Stage 7.
- Backend — Go (backend/). Single binary that serves a ConnectRPC API and the embedded web frontend.
- Frontend — React + TypeScript + Vite + Tailwind (web/). Talks to the backend over ConnectRPC (JSON over HTTP).
- Schemas — Protocol Buffers (proto/), compiled with Buf into typed Go server stubs and typed TS clients.
- Database — PostgreSQL with row-level security keyed off the
per-request
app.current_tenant_idGUC. Migrations run as a superuser; the application connects as a separate non-super role (stillhouse_app) so the RLS policies actually enforce. One tenant = one CRA spirits licence. - Mash guidance — gelatinisation ranges, amylase activity windows, pH and thickness bands come from the IBD/CIBD distilling curriculum and are cited on the constant that carries them (backend/internal/mashing). Where the curriculum gives no figure for a cereal, Stillhouse reports it as unknown rather than interpolating.
- Alcoholometry — strength and volume are resolved to 20 °C against the CRA Canadian Alcoholometric Tables 1980, computed from the OIML general formula (International Recommendation No. 22, 1972). The tables are Crown material and are not shipped — you download them from CRA once and point the server at the file (see deploy/README.md); until you do, readings record uncorrected and nothing else changes. With a copy present the package replays all 117,137 published rows back through the lookup, and CRA's own worked examples, as tests.
- License — AGPL-3.0. Free to self-host, forever. See LICENSE, NOTICE for third-party material, and CONTRIBUTING.md before opening a PR.
proto/ Protocol Buffer definitions
backend/ Go server
cmd/server/ binary entrypoint
cmd/seed/ bootstrap a starter tenant + admin user
internal/audit/ audit log writer
internal/auth/ Argon2id password hashing
internal/config/ env config
internal/db/ pgx pool, migrations, sqlc-generated queries
internal/distilling/ projection math (mass → sugar → ethanol → LAA)
internal/excise/ Canadian excise duty rates + Owed() helper
internal/genpb/ generated Go from .proto
internal/rpc/ ConnectRPC service implementations
internal/server/ HTTP server + session middleware + static embedding
internal/tenantdb/ WithTenantTx — opens a tx, sets the tenant GUC,
runs your callback with a tenant-scoped Queries
web/ React frontend
src/gen/ generated TS Connect client from .proto
src/pages/ per-route views (Materials, Recipes, Mashes, …,
Bulk, Barrels, Products, Stamps, Bottling,
Removals, B266 returns, Audit log)
deploy/ Dockerfile + dev compose
.github/workflows/ CI
# Install required tools (Go binaries: buf, sqlc, golang-migrate, protoc plugins).
make tools
# Start a local Postgres in podman/docker.
make dev-up
# Apply migrations (creates the schema + the stillhouse_app role).
make migrate-up
# Seed a tenant + admin user. Prints the random password — capture it.
make seed
# In two terminals:
make backend-dev # Go server on :8080, connected as stillhouse_app
make web-dev # Vite dev server on :5173 (proxies API to :8080)Then open http://localhost:5173 and log in with the seeded credentials.
The RLS isolation test exercises two real tenants through both the
admin pool (to insert fixtures) and the app pool (to verify each
tenant sees only its own rows). Requires make dev-up.
make test-integrationStillhouse exposes a Model Context Protocol
server at /mcp, so an LLM (e.g. Claude on your phone) can read the
ledger and capture activity while you have wet hands at the still.
It reuses the same RPC service implementations as the web UI, so RLS tenant isolation, audit-log writes, and role gating all behave identically — an MCP-driven barrel fill leaves the same trail as a web-driven one.
Tokens are per-user. The plaintext value is printed once; only its SHA-256 hash is stored.
make mcp-token EMAIL=you@example.com NAME="phone"Configure a remote MCP server pointing at your Stillhouse install:
- URL:
https://stillhouse.example.com/mcp - Header:
Authorization: Bearer sh_…(the token printed above)
- Read —
get_dashboard,list_bulk_containers,get_bulk_container,list_barrels,get_barrel,list_recent_bulk_movements,list_recipes,list_products,list_b266_periods - Filing —
review_filing: whether the current return is ready, and what to resolve first — losses awaiting a duty treatment, outstanding blockers, and whether the return continues the last one filed. Reads only; it does not generate or submit anything, and ordering the work is the point of it. - Capture —
fill_barrel,regauge_barrel,dump_barrel,add_fermentation_reading,add_mash_reading - Bench —
get_mash(grain bill + readings + mash guidance),plan_strike(strike temperature for a target rest),plan_reduction(proofing down, by volume or by weight),plan_blend(vatting parcels together, optionally reduced)
Barrel fill / regauge / dump and the production gauge accept a hydrometer
indication (density_kg_m3) plus temperature_c; supply both and the
strength and volume are resolved to 20 °C through the published CRA tables
rather than taken as typed.
Back-office work (distillation runs with cuts, bottling, removals, B266 generation) stays in the web UI — those flows have multi-row inputs that don't translate cleanly to a chat interface.
See CONTRIBUTING.md. The short version: cite your sources for anything that produces a number a distiller relies on, don't invent domain constants, and keep alcohol conserved.
First pull request? A bot will ask you to sign the CLA — one comment, once. Stillhouse stays AGPL-3.0 regardless; the CLA exists so the project can also be offered commercially, and it doesn't affect your copyright in your own work.
Security issues: SECURITY.md — please don't open a public issue.
Stillhouse is licensed under the GNU Affero General Public License v3.0.
You can run it, read it, modify it and self-host it for free, forever. The AGPL's network clause means that if you modify Stillhouse and offer it to others over a network, you have to offer them the source of your modified version too.
NOTICE records third-party material. Note that the CRA's Canadian Alcoholometric Tables are deliberately not included — each operator supplies their own copy — which keeps commercial redistribution of Stillhouse itself clear of the Government of Canada's non-commercial reproduction terms.