Changelog
Every entry on this page is shipped, verified work: no
aspirational entries, no roadmap dressed up as progress. This page is
generated directly from the repository's CHANGELOG.md by
site/build_changelog.py, so the public log and the real build
log are the same document.
Public-format build log, week 1 onward. Every entry is shipped, verified work: no aspirational entries (see spec/05-team-charter.md non-negotiables).
Week 5
- 2026-08-11: A large first sync now waits for Shopify's rate limit instead of failing at it. Shopify caps how much an app can pull per second, and it enforces that cap in a way that is easy to misread: the refusal often arrives as an ordinary success response with the error tucked inside it. We were treating those as fatal, so a big import could stop partway through and report a failure that really meant "too fast". Tilltrend now reads the budget Shopify returns alongside every response and slows itself down before it runs out, and on the occasions it still gets refused it waits exactly as long as Shopify asks and then carries on from where it stopped. For a store with a large catalog that means the first sync takes as long as it takes, instead of needing someone to notice and start it again. Said plainly because it is the honest part: no store has hit a real rate limit yet, so this is built to Shopify's published limits rather than to a failure we watched happen. What we can show is that the handling itself is tested, including ten deliberate breakages of it that the test suite caught.
- 2026-08-10: The Power BI and Tableau starters cover every governed view again, and the caveats they carry are current. The starter files had been generated a while back and had quietly fallen behind the warehouse. Four governed views added since then (per-product monthly, quarterly and yearly trends, plus the trailing-twelve-month product view) were missing from the Power BI model, so they never showed up in the field list even though the data was there. The governed caveat that travels inside each measure description had drifted from the definition it quotes, and each measure's definition stamp pointed at a version that no longer existed. Both starters are regenerated: the model now exposes the whole governed layer, and every description matches the definition it names. Nothing you have already built on the old file changes, because no measure changed its name, its formula or its number format. The part that lasts is smaller and duller: a starter that falls behind the generator it came from now fails our test suite, instead of waiting for someone to notice.
- 2026-08-09 (latest): Uninstalling now clears your API tokens, and reinstalling starts from an empty list. When you uninstall, Shopify asks us to delete your shop's data. We did that for your warehouse, your shop record and your onboarding state, but not for the personal API tokens you may have created on the Export page. Two things followed from that, and both are fixed. Those token records stored your shop's domain, so a piece of identifying information outlived the deletion it should have been part of. And because a shop that comes back is recognised as the same shop, reinstalling later meant meeting up to five tokens you could no longer use, already filling the five-token limit, with no way to create a new one until you revoked each of them by hand. Uninstalling now removes them alongside everything else. Nothing changes for a shop that stays installed: your existing tokens keep working exactly as they did, and revoking one is still the only thing that deletes it.
- 2026-07-29: Your best sellers, for a recent window instead of only all time. The Products page ranked your catalogue by revenue since the beginning, which is the fairest measure of standing and the slowest to notice a change. Underneath that ranking there is now a second one you can point at a window: the last 12 months, the last quarter, or the last month. Two things about it are deliberate. Each window is a completed period, never a half-finished one, because a month that is three days old ranks three days of sales and reads like a collapse. And when a window holds too few orders to separate one product from another, the page says so in plain words rather than letting the order look meaningful. The all-time ranking above it has not moved, and every number still traces to the same governed metric with the exact dates it covers printed underneath.
- 2026-07-29 (latest): Ask can answer questions about a period. You can now ask things like "what was my top selling product last month" and get a ranked answer with a receipt, instead of a refusal. Periods are worked out from the latest order in your data rather than from today's date, so the answer stays honest when a sync is a few hours behind, and the window it used is written into the answer. As always the wording tells you which parts are measured and which are the model's own reading.
- 2026-07-29 (later): The receipt panel and the audit log stop quoting our own file paths. The receipt under an Ask answer used to end with a command typed against our source code, and the audit log introduced itself by naming the file it lives in on our server. Neither is something you can open, so both now say what they promise instead. The receipt states that it re-runs its own query and re-checks the result with the model taken out of the loop, and the audit log states that entries are only ever added, never rewritten or deleted. If you want the exact command, it still travels with the receipt inside the CSV you export from that same panel. Nothing about your numbers, your receipts, or the stored record behind them changed.
- 2026-07-29: An Ask answer stops labelling itself twice. The card holding the plain-language summary is already headed "What this means", with a note that the wording is the model's own and carries no receipts. The summary itself sometimes opened by repeating that label, so a single card said the same thing two ways, and the exact audited text below it did the same. The repeat is gone from both, so the two still match each other word for word. Nothing about the answer, its numbers or its receipt changed, and the stored record behind it is untouched.
- 2026-07-28 (later): Hover text looks like the app, and the menu fits a laptop screen. The note explaining why there is no Funnel page used to arrive as a browser tooltip: a wide strip in the system font, stretched most of the way across the window and dropped over the page. It now appears in a small panel that matches the rest of the app, and on a phone the same reason is printed under the entry, where no hover exists. Hover labels that only repeated a name you could already read are gone. On shorter laptop screens the left menu no longer runs off the bottom of the window: every entry, including the light and dark switch, fits at 1280x720 and up. That switch also sits clear of the link address your browser previews in the bottom-left corner, so hovering a menu item no longer hides it.
- 2026-07-28: The small print under your numbers stops talking to us. Every KPI and chart carried a footnote naming an internal file as the home of its definition, and the bottom of every page pointed at a script only we can run. The footnotes now name what is useful to you, the governed metric and the view it came from, and the footer states the promise instead: every number traces to a governed metric, and every Ask answer keeps a replayable receipt. Losing the file path also pulled each KPI footnote back onto a single line. The greyed Funnel entry in the menu explains itself now. It used to end by telling you to see a file that exists only on our machines. It says what is actually missing instead: your orders record what sold, not the visits, product views and carts that came before checkout, and we will not estimate those. The page ships when a storefront analytics connector does. On a phone, where nothing has a hover, that reason is printed under the entry rather than hidden inside one.
- 2026-07-26: The health-check report says "(no category)" like the rest of the app, and drops its dashes. A product without a category used to show a long dash in the report's Top products table, while the Products page and an Ask answer called the same thing "(no category)". All three surfaces now use the same words, produced by one piece of code, and a check fails the build if they ever drift apart again. Everywhere else the report showed that dash for a value it did not have (a missing amount or count, an empty field in the receipt panel), it now shows a plain "-". The sample report on this site is regenerated to match. In Ask, the table headers for period-over-period change now read WoW, MoM and DoD, the way reports usually write week-over-week and month-over-month, instead of WOW and MOM. The note under a comparison where one period has no data reads more cleanly, and a rare formatting fault that could garble a web address in the model's commentary is fixed. Later the same day the report caught up with the brand: the header now carries the current Tilltrend wordmark, and the segment bars use the same shape and colours as the app's other charts. This applies to the in-app report, the PDF you print from it, and the public sample on this site.
- 2026-07-25: An Ask answer now opens with a readable result, not a wall of fixed-width text. Every answer used to arrive the same way whatever you asked: one block of fixed-width text with tables drawn out of spaces. An answer now leads with a readable card. A single figure is shown large, and a result with several rows is a real table you can scan down a column. Money carries your store's currency symbol and two decimals, percentages carry a percent sign, and numbers keep a gap from the column beside them instead of touching it. A very wide result loads much faster than it did. The exact text you saw before is still there, unchanged to the character, one click away under "Show the exact audited text". The card is not a rewrite of your answer: it is a second rendering of the same stored receipt the old text was rendered from. Nothing reads the answer back and interprets it, so what is stored is still what is shown. And if the card cannot be built for any reason, the exact text leads instead, so a display problem can never hide an answer. A few things that were quietly wrong are now right. The year 2026 printed as "2,026" and an order number as "12,345", right-aligned as though you could add them up; years, order numbers and product codes now read as the labels they are. A missing category reads "(no category)", the same words the Products page has always used, instead of the database word NULL. A missing amount or count still reads NULL, because there is no honest phrase for a number that was never measured, and the note explaining that a NULL means "no data in this scope, not zero" is unchanged. The line describing what you asked, the dates and the filters, now shows your request exactly as stored, so a filter value is never restyled. The model's optional "What this means" paragraph is still labelled as the model's own wording with no receipts behind it, and it now reads word for word the same as the audited text below it; the two could previously differ in punctuation.
- 2026-07-25: Tilltrend introduces itself the first time you open it. A short animation of the Tilltrend mark now plays when you open the app for the first time. It plays once ever and you can skip it; either way it does not come back. It is the same motion as the one on our homepage.
- 2026-07-25: When Ask asks you a question back, it now says who wrote it. If your question was ambiguous, Ask replies with a clarifying question rather than a number. That reply was being shown under the same "governed text" heading we use for answers quoted from our metric definitions, which was wrong: a clarifying question is written by the model, and on that kind of turn there are no receipts, so nothing checks it. It now appears under its own heading that says plainly it is not a governed answer and that nothing was computed. The genuinely governed refusals, the ones quoted word for word from our definitions, are unchanged and still say so. We also made sure a clarifying question can never contain a figure: if the model writes one, we replace the text with our own wording instead of showing you an unchecked number next to numbers that carry receipts.
- 2026-07-25: Charts, amounts, and the Ask page read better. A revenue total in the category mix could print a long trail of decimals (a rounding artefact of adding the numbers up, not a wrong figure); totals are now added exactly, and every money amount shows two decimals like a currency should. Bars across every chart have softened ends, rounded only where a bar actually ends and never in the middle of a stacked bar, so the shape still reads as a measurement. On the Ask page, the question box no longer floats over the answer you are reading, long lines wrap instead of running off the side, and the buttons across the app now share one colour and one corner. None of this changes a single number: your receipts and the exact audited text behind every answer are untouched. On the Products page, changing a product's trend grain (month, quarter, or year) or picking a different product used to reload the page with the "Product trends" section collapsed and the view snapped back to the top. It now keeps that section open and returns you right to it, so comparing one product across month, quarter and year stays smooth instead of feeling like the page reset.
- 2026-07-19: We code-reviewed the past week's work and fixed all ten findings. A structured review of the recent changes (the animated homepage, the answer-prose cleanup, and the backup-status signal) surfaced ten real issues; every one is fixed and live. The ones you might have noticed: Ask's prose could occasionally glue two words together around punctuation, and if a product name or link in your catalog contains a long dash, the prose now quotes it exactly as your catalog spells it instead of restyling it. The backup-health signal we publish for our own monitoring now treats an implausible timestamp as a fault instead of reporting "ok", so a silent backup failure cannot hide behind a broken clock. And on the website, the homepage animation now picks its tuning by screen size (tablets got a mismatched middle ground before), and the open menu reliably covers the animation on older browsers.
- 2026-07-13: The export bundle now explains how its tables fit together. Every data export (the Excel/CSV, Power BI, and Tableau bundles) ships a data dictionary describing each table; it now opens with a short "How the tables relate" section listing the only join keys that line up across the files: product performance and market-basket rows to the product dimension, daily sales to the calendar, and says plainly that every other table is a finished, self-contained aggregate needing no join. The section is built from the actual contents of your bundle (a join is never documented to a table that isn't there), and the customer directory is explicitly marked standalone: per-customer sales detail deliberately never leaves the platform.
- 2026-07-10: Honest failure states, fresher trends, and a faster Products page. A review pass over the recent product-trends work fixed ten issues. The ones you might have noticed: if a product's trend can't be queried for a moment (mid-sync, for example), the page now says exactly that and suggests "Sync now", it no longer misreports "No dated sales yet"; on stores with years of history, the daily Trends view now always shows the most recent days (a row cap could previously leave the "latest" figures stale); and the Products page renders noticeably faster on large catalogs. Behind the scenes, a billing safeguard now ignores a stale cancellation notice from an old, replaced subscription, so changing plans can't lock a paying store out. The plan chooser also now says precisely what every plan includes: the per-answer CSV download is on all plans; the downloadable BI bundle and API tokens are the Studio extras.
- 2026-07-10: A public Support & FAQ page is live. tilltrend.app/support.html now says plainly how to reach us (contact@tilltrend.app, a human reads every mail, first reply within one business day) and answers the questions merchants actually ask: how syncing works, what the plans include, how to export or delete your data, and why some fields honestly read "unknown" instead of a made-up number. Every page on the site links to it from the footer.
- 2026-07-09: See whether any single product is rising or dying. The Products page has a new "Product trends over time" view: pick any product and a grain (month, quarter, or year) to see that one item's revenue trend over time, using the same governed, replayable time-series the store-wide Trends page uses. The trend starts at the product's first sale, and a period with no sale reads as an honest 0, so nothing is faked before the product existed or after it stopped selling.
- 2026-07-09: A calmer, cleaner dashboard. A visual pass across the app: the charts now share one restrained "money-green" instead of a bright neon, with a muted rose and gold in place of the loud pink and amber, so nothing shouts for attention. The long Products and Trends pages open to just the headline and fold the deeper breakdowns behind one-line summaries you expand when you want them; the side navigation is larger and easier to read; and a handful of chart labels that could crowd each other were tidied up. Same numbers, same receipts on every figure: just easier on the eyes.
- 2026-07-07: Data export is now part of the Studio plan. Studio subscribers can take their numbers out of Tilltrend and into their own tools: download the governed dataset as an Excel, Power BI, or Tableau bundle, or mint a personal API token to re-pull a fresh snapshot on a schedule, every figure still traceable to its receipt, and each file read on its own, with no live connection back into Tilltrend. Starter and Pro keep the full in-app product: all the revenue, product, and cohort views, the receipts, and the per-answer CSV download, with the downloadable bundle and API tokens reserved for Studio. The free trial includes everything, so a store can try the full export before choosing a plan.
- 2026-07-07: The first live data-deletion order arrived, and was honored on schedule. Forty-eight hours after a store uninstalls, Shopify sends the app a mandatory "erase this shop's data" order. The first live one (for a test store we uninstalled two days ago) fired overnight, and this morning's check on the server confirmed both halves: the store's record is gone and its entire private warehouse database was dropped. Deletion on uninstall is now verified behavior, not just a line in the privacy policy.
- 2026-07-07: The pages outside your dashboard now look like Tilltrend too. Choosing a plan, the install welcome, and the rare billing-error page used to wear a plain, off-brand look: fine at week one, jarring at the exact moment a merchant decides to pay. Those pages now carry the app's own palette and brand mark, follow your system's light or dark preference, and the plan cards read like the dashboard they lead into. Words and behavior unchanged: verified with screenshots in light, dark, and Founding-deal variants before it shipped, and on the live URL after deploy.
- 2026-07-07: Simultaneous updates can no longer overwrite each other's bookkeeping. On subscription day Shopify tells us twice at once: the approval you click and a webhook, and each used to rewrite the whole shop record, so an unlucky overlap could erase the plan name the other had just saved (the same name your Ask allowance is read from). Every record write now goes through locked, field-level merges instead. The tests bite: with the locks deliberately removed, 200 simultaneous Ask counts collapsed to 25 and some writes errored outright; with them, counts stay exact. Nine new automated checks pin the behavior.
- 2026-07-06: Ask's plan limits are now real, and you can always see where you stand. Each plan has always advertised a monthly Ask allowance (Starter 300, Pro 2,000, Studio unlimited), but nothing counted. Now every answered question counts toward the month, the Ask page quietly shows "N of 300 used this month · resets August 1", and a store that reaches its limit gets a plain notice with the reset date and an upgrade link, instead of a silent failure or a surprise. The boundaries lean the merchant's way: a question that errors out doesn't count, a paid store whose plan we can't confirm is never blocked, and the limit resets on the first of each month. Twenty-one new automated checks pin the behavior.
- 2026-07-06: We code-reviewed our own sign-back-in feature and fixed what we found, before any merchant could hit it. The new "opening the app signs you back in" flow reruns the install handshake, and a deep review caught the handshake still carrying its first-install side effects: signing back in could quietly reset a paying store's subscription record to a fresh trial, and re-download the store's data on every re-entry. Both are fixed: only a genuine first install starts a trial and the first sync; signing back in changes nothing. Also from the same review: two data syncs of the same store can no longer run at once; a leftover login cookie from an erased install no longer blocks re-entry; a merchant with several stores who opens the app from store B while signed into store A now lands in B, not A; and a handful of smaller edge cases in who sees the install page versus the password prompt. Nine new automated checks pin every fix.
- 2026-07-06: Changing your plan is now one click from the dashboard. Plan changes have always worked through the billing page, but once you subscribed, nothing linked to it: you had to know the address. Now the dashboard header quietly shows your active plan with a "Change plan" link. Verified live the same day: a test store switched from Pro to Starter through Shopify's own approval screen, no reinstall, no support ticket, which is exactly the standard Shopify's app review holds billing to.
- 2026-07-06: Opening the app while signed out now signs you back in instead of locking you out. If your login session expired and you opened Tilltrend from your Shopify admin, you previously hit a bare browser password prompt: a dead end. Now the app recognizes a genuine Shopify open (the request carries Shopify's own signature), verifies it, and quietly signs you back in through the normal install handshake. And anyone visiting the app's address directly, including Shopify's reviewers, sees a proper welcome page pointing to the Shopify admin, not an error. Verified live on the public URL after deploy.
- 2026-07-06: Ask answered its first question from the cloud, after we fixed why it couldn't. During a full dress rehearsal on a fresh test store (install → sync → ask → subscribe, recorded end to end for Shopify's app review), the Ask page failed on the cloud server: a development-only AI component was baked into startup even though the cloud path never needs it. The fix loads that component only where it exists; in the cloud, Tilltrend now talks to the AI service directly. Verified live: the rehearsal store synced 65 orders, asked "What were my best products last month?", and got the receipted answer, the AI's prose passed the fact-check gate, and every number traces to a replayable receipt.
- 2026-07-06: Nightly backups are now encrypted. The warehouse's belt-and-suspenders nightly backup (kept alongside the managed database's own point-in-time recovery) is now AES-256 encrypted the moment it's written, with the key stored separately and escrowed offline. Verified by decrypting a fresh backup and reading its catalog back. A backup that leaks is unreadable; a backup we need still restores.
- 2026-07-05: Leaving Tilltrend now works as cleanly as joining it, because we tested it. After the subscription test passed, we uninstalled the app on the test store and watched what happened: nothing. The handling code existed and was fully tested, but Shopify had never been given the delivery address for its "app uninstalled" and privacy-cleanup notifications: a gap invisible in code review, found only by actually leaving. Fixed the same night: all five notification types (uninstall, subscription changes, and the three privacy/data-erasure notices Shopify mandates) are now wired to the live service, and the app moved to Shopify's modern managed-installation flow in the process. Verified by reinstalling and uninstalling again: the departure was recorded within seconds. Merchants who leave get their departure honored, and their data-erasure requests actually arrive.
- 2026-07-05: Store connections meet Shopify's newest security standard, and the full subscription flow is verified end to end. Shopify now requires apps like Tilltrend to use short-lived access credentials that renew themselves, instead of one permanent key. That's live: every new store connection gets a one-hour credential plus a sealed renewal token, Tilltrend refreshes it before it's needed, and if Shopify still rejects a call mid-flight, the app renews and retries once automatically. With that in place, the previously blocked final step of the billing test passed on a test store: pick a plan, approve Shopify's charge screen, and the subscription activates, confirmed against Shopify's own records, never our assumption. No real money moved (test mode), and existing store connections were untouched.
- 2026-07-05: Plans are wired in: Starter $39, Pro $99, Studio $249. Tilltrend's pricing now lives in the app: a plan chooser shows the three tiers, each with a 14-day free trial and a clear Ask-questions allowance, and every charge runs through Shopify's own Billing API, so we never see card details. Verified live on a test store: the chooser renders the exact ratified prices. Honest status: no one can be charged yet, billing stays in Shopify's test mode until launch, and the final approval step is currently blocked on adopting Shopify's new expiring access tokens (their 2026 requirement for public apps); that upgrade is next. Also shipped: when a billing step fails, you now get a plain-language page saying exactly why, instead of a blank gateway error.
- 2026-07-05: A brand-new store can install itself in the cloud, end to end. First fresh install on the new infrastructure, verified with a test store: approve the connection, and Tilltrend provisions a private warehouse for that store on the managed database and runs its first sync automatically, no hands, no laptop, nothing shared with any other store's data. Two setup gaps found and fixed in the process, which is exactly what a test store is for.
- 2026-07-04: The workstation is retired: the cloud is now Tilltrend's only home. The follow-up to "Tilltrend is now always on": after the cloud server ran unattended with the old workstation switched off, 19 of 19 independent health checks green across the whole soak, the old Windows serving stack was decommissioned for good. The cloud server is no longer the primary; it's the only one, watched by the independent monitor and backed by nightly proven backups.
- 2026-07-04: Backups are now proven, and the service watches itself. Three additions to the new cloud home, each verified before this entry was written. First, we didn't just enable backups, we ran a full restore drill: a backup of the live warehouse was restored into a separate scratch database and every table's row count matched the original exactly. A backup you haven't restored is a hope, not a backup. Second, an automated nightly backup now runs on the server itself, with seven days retained and a guard that refuses a suspiciously empty backup file. Third, an independent monitor outside our own infrastructure now checks the service's health every 15 minutes, around the clock, and alerts us on any failure, and we deliberately triggered a failure to prove the alert actually fires. If Tilltrend goes down, we find out from the monitor, not from you.
- 2026-07-04: Tilltrend is now always on. The dashboard, your data warehouse, and the nightly sync moved from a single workstation to a cloud server with a managed database. Until now the service was only reachable while that one machine was awake: overnight and during restarts it was simply off. That window is gone: the app answers around the clock, independent of any PC. Nothing about your access changes: same address, same login, and store credentials stay encrypted at rest on the new host exactly as before. Every governance guarantee was re-verified on the new infrastructure before traffic moved: analytics connections are still read-only, unsafe queries are still refused and audited, and runaway queries are still cut off. Verified live, including a fresh automatic sync rebuilding the warehouse in the cloud, before this entry was written.
- 2026-07-03: Store credentials are now encrypted at rest. The access credential your store hands Tilltrend when it connects, and the database address for your warehouse, are now stored encrypted (AES with integrity protection), not as readable text in a file. The encryption key lives only in the server's protected environment, never alongside the data it protects, so a copied data file on its own yields nothing usable. Key rotation is built in, and the server's health endpoint now states plainly whether secrets are sealed, the same no-guessing rule as everywhere else: we report the mode that's actually running, not the one we intend. Verified live before this entry was written.
- 2026-07-02: Export bundles now record your store's currency. The
manifest.jsonin every export bundle carries acurrency_codefield: your store's own currency, read from Shopify at sync time (Tilltrend never converts; your amounts were always in it). On some setups the sync skipped that lookup, so the manifest saidnulleven while the dashboard showed the right symbol. Fixed: every sync path now records it, and the manifest only ever states the currency actually captured from your store, never a guess. - 2026-07-02: Your Power BI file now opens onto a real report page, with the governance stamp on the canvas. Two fixes in one for the Build my Power BI file bundle. First, the fix: the file always opened and loaded your data, but the report page itself could come up blank on current Power BI Desktop, no page tab, an empty canvas. The project file now matches exactly what Power BI Desktop itself writes, so the report page appears the moment the file opens. Second, what you see on it: a banner across the top of the page that says plainly "Governed snapshot - data as of [date] … NOT a live connection", with your data window and build id, the same honesty stamp the page tab already carried, now impossible to miss. Every bundle's READ-ME also now explains how to automate the download with your API token, including the exact one-line command.
- 2026-07-02: Automate your data export with a personal API token. The Export page now has an Automate it section: create a personal API token and your scripts can download any bundle on a schedule, a nightly
curlwith a Bearer token instead of a browser and a password. A token unlocks only the export downloads (up to 20/hour): it can't open your dashboard, run queries, or create more tokens, and you can revoke it on the same page to cut a script off instantly. You see the token once when it's created; Tilltrend keeps only a hash. Every use, and every refused use, lands in the audit log. - 2026-07-01: Take your governed data into Excel, Power BI and Tableau. A new Export page lets you download your store's governed analytics as a self-contained bundle: the same numbers as your dashboard, with the caveats attached. Download my data gives every metric as CSVs that open straight in Excel or Google Sheets (no install, no password). Build my Power BI file gives a ready governed model: your metric views as tables, with measures, drill-downs and currency formatting already set up, that opens in the free Power BI Desktop; we cold-open-tested it and it loads cleanly with your data. Build my Tableau file works with the free Tableau Public (open the bundled CSVs and paste the governed calculations we include) or with Tableau Desktop. Every bundle reads its files offline: no live connection into Tilltrend, nothing to log into, and customer names and birthdates are never included. It's a point-in-time snapshot, so come back for a fresh download when you want current numbers; and any measure you write yourself isn't governed by Tilltrend: the governed definitions, with receipts, stay in the app.
- 2026-07-01: Your report now shows the receipt behind every number. The Store Data Health-Check report (the one you read in the app, the PDF you can save, and the public sample anyone can view) now shows its work right on the page. Behind the top-products table there's a receipt panel with the exact SQL Tilltrend ran, the data build it ran against, the content hashes, and a one-line command to replay the figure yourself with no AI in the loop; that same receipt now travels inside the CSV download too. So "every number carries a receipt" is something you, or a sceptical analyst, can see and check on the spot, not just read about. The same pass cleaned up the report itself: the real Tilltrend logo now sits in the header (it was plain text), the "Store Data Health-Check" title that had been showing white-on-white, invisible, is fixed, and the spacing, document card and tables are tidier. It reads correctly on a phone now, too: a large revenue figure that used to clip fits, and the provenance details stack cleanly. Applies to the in-app report and the saved PDF alike; no number, or how it's computed, changed.
- 2026-07-01: A proper mobile menu on the website. The marketing site now has a hamburger menu (☰) on small screens that matches the one in the app, replacing the cramped, wrapped row of links that didn't fit on a phone. The desktop navigation is unchanged.
- 2026-06-27: A cleaner, at-a-glance Overview. The dashboard Overview is now a one-screen digest. The Customers, Products and Geography breakdowns fold into tidy collapsible sections, each showing a quick summary right on its header: how many customers and their average lifetime value, your product count and top seller, how many countries and the leading one, so the highlights are visible without expanding. A new Products section brings the top products and the category revenue-mix onto the Overview, and a Revenue over time chart now sits directly under the headline numbers. Every summary figure is governed (never a fabricated 0), and the trend shows an honest note rather than a faked line until a store has more than one month of data. Reads well on phones too. Zero-JS, $0, dependency-free.
- 2026-06-27: New brand typeface and an app-style website. Tilltrend now uses its brand typeface (DM Sans) across the dashboard and the website, and the marketing site has been redesigned to match the product: a dark, app-style look with the emerald accent that reads cleanly on desktop and phones. The font is self-hosted (no third-party font CDN: nothing is fetched from elsewhere when the page loads). No number, and no way any number is computed, changed.
- 2026-06-27: Plum is now Tilltrend. New name, new logo, and an emerald palette across the merchant dashboard and the public site. Same product, same governed-metrics-with-receipts: only the name changed; existing stores keep all their data and history. Your dashboard now also refreshes automatically every night, so new orders appear without a manual sync.
- 2026-06-21: Connect Tableau to your governed metrics. Prefer Tableau? There's now a starter for it too: five text-only
.tdsdata sources that connect live to the gold views (through the read-onlyplum_birole), a sales star plus the pre-aggregated views, with the same 8 governed calculations as the Power BI kit, re-expressed in Tableau syntax (Average Order Value as the ratio of sums, never an average of monthly AOVs; Median LTV, the honest figure, not the mean; Active Customers as a non-additive distinct count), each carrying its caveat in the field description. Your Tableau workbooks inherit the same honest numbers as Ask, the dashboard, and Power BI. Text-only, $0, no new dependencies. (Live database connection needs Tableau Desktop: Tableau Public has no PostgreSQL connector.) - 2026-06-21: One-click Power BI starter. The Power BI starter is now a double-click project (
plum-starter.pbip): open it and it loads the gold tables and all 8 governed measures, ready to drop visuals onto, no manual model setup. (The connection-only file is still there if you prefer a blank report.) - 2026-06-21: Connect Power BI to your governed metrics. A starter Power BI kit: a one-click connection to the gold views (through the read-only
plum_birole) plus a governed semantic model whose DAX measures are 1:1 with Plum's metric definitions, Total Revenue, Orders, Units, Active Customers, Average Order Value (ratio of sums, never an average of monthly AOVs), Median LTV (the honest figure, not the mean), and Repeat-revenue share, each carrying its caveat. Your Power BI reports inherit the same honest numbers as Ask and the dashboard. Text-only, $0, no new dependencies. - 2026-06-21: Bigger Trends charts, with the edge dates fully clear. The time-series charts on the Trends page are now taller and have more breathing room at the sides, so the first and last dates, and the value tag on the latest point, sit comfortably inside the chart instead of hugging (or clipping at) the edge.
- 2026-06-21: "Frequently bought together" and purchase cadence, now on screen. The market-basket and purchase-cadence metrics get on-screen surfaces: a Frequently bought together table on the Products page (the product pairs that show up in the same orders most often, with how many orders and the share), and a Purchase cadence panel on the dashboard (the typical days between orders for repeat customers, median first, with the average and the slow-10% tail). Honest as ever: co-occurrence is flagged as correlation, not causation, and cadence covers the repeat base only. Zero-JS, $0, dependency-free.
- 2026-06-21: Two more answers: "frequently bought together" and how often customers reorder. Market basket ranks the product pairs that show up in the same orders most often (with how many orders, and what share of all orders): handy for bundles and cross-sell, with the honest caveat that it's co-occurrence, not proof that one drives the other. Purchase cadence shows how often repeat customers come back: the typical days between orders (median, plus the average and the slow-90th-percentile tail) across customers with two or more orders. Both answerable in Ask, over MCP, and in Power BI / Tableau. $0, dependency-free.
- 2026-06-21: Charts for dead stock and customer age. The dead-stock view (products grouped Active / Slow-moving / Dead by how long since their last sale) now appears as a chart on the Products page beside the performance tiers, so you can see at a glance how much of the catalog (and how much past revenue) is sitting idle. Buyers by age group is now a chart on the dashboard, with an honest "Unknown" bar and the exact birthdate coverage when ages aren't on file. Zero-JS, $0, dependency-free.
- 2026-06-20: Charts for customer recency and product tiers, plus tidier date labels. The customer recency (Active / At-risk / Churned) and product performance tier (High / Mid / Low) breakdowns now appear as charts in the app: recency beside customer segments on the dashboard, product tiers on the Products page, each a clean split of share-of-customers (or products) versus share-of-revenue, with the cut-offs disclosed under the chart. We also tidied the date labels on the Trends charts: they're now compact (e.g. "Dec '10", "Q4 '10", "Dec 29") and aligned so the last date no longer runs off the edge or overlaps on a phone: the full date is still in the table and on hover. Zero-JS, $0, dependency-free.
- 2026-06-20: Spot dead stock, and see your customer age mix. Two more governed metrics: answerable in Ask, over MCP, and in Power BI / Tableau. Product recency flags dead stock: products grouped Active / Slow-moving / Dead by how long since their last sale (measured against your latest data), so you can see at a glance what to restock, push, or retire, and how much past revenue is tied up in it. Customer age groups break identified buyers into the usual brackets (under 20, 20–29, 30–39, 40–49, 50+). Age needs a birthdate on file, which many stores (Shopify included, by default) don't collect; when it's missing the metric says so honestly with an "unknown" group and the exact coverage, never invented demographics. $0 and dependency-free, as ever.
- 2026-06-20: Know your customers and products at a glance: segments, recency, and performance tiers. Three new governed metrics, each answerable in Ask, over the MCP connection, and in Power BI / Tableau, from one shared definition. Customer segments (VIP / Regular / New) split customers by how much they spend and how long they've been buying, now shown as a chart on the Overview page where the gap between "share of customers" and "share of revenue" tells you where the money really is. Customer recency flags who's Active, At-risk, or Churned by time since their last order (measured against your latest data, so it reads "now" for a live store). Product performance tiers group products into High / Mid / Low by revenue. The cut-offs (what counts as a "VIP", "at-risk", or a "high-performer") are yours to set, and the exact values are shown on every answer's receipt, so a number always states the rule it used and you can verify it. $0 and dependency-free, as ever.
- 2026-06-20: A friendlier dashboard on your phone. Went through every admin page on a phone and reworked the navigation for small screens. The old cramped strip of tiny, unlabelled icons is gone: there's now a hamburger menu (☰) that opens a floating, translucent panel of clearly labelled pages from the side; tap the ✕ or anywhere outside to close it. The Trends grain selector now wraps instead of cutting off "Year", and a few layout tweaks keep text and tables sitting cleanly within the screen. The menu uses no JavaScript, and the desktop layout is unchanged.
- 2026-06-20: Smaller Trends charts, and a single home for revenue over time. The Trends charts now render at a more compact size on screen, and the separate Revenue page has been folded into Trends: its month view already shows the same monthly revenue plus a running total, a trailing moving average, and month-over-month / year-over-year change, so there is now one more-capable place for "revenue over time" instead of two overlapping ones. The governed monthly-revenue definition is unchanged: Ask still answers "monthly revenue" with the same receipted numbers and exact SQL.
- 2026-06-20: Trends: sales by day, week, month, quarter, and year, with charts. A new Trends page answers the time questions a merchant actually asks: revenue by day / week / month / quarter / year, the latest period, and the most recent day, each as a line/area chart with a running total to date, a trailing moving average, and the change vs the prior period (and, for months and quarters, vs the same period last year). Pick the grain with one click. Underneath it's a proper BI date dimension (
gold.dim_date) and five governed period views built with window functions and CTEs (runningSUM, trailingAVG,LAG) over your sales, so a period with no orders shows an honest 0 (never a gap), the partial first/last period is flagged (not read as a trend), and a period-over-period change with no prior period reads "no prior period" rather than a fabricated number. Every grain reconciles to the same total. The same governed metrics power Ask, so "show me weekly revenue" now returns a receipted answer with the exact SQL: $0 and dependency-free, as ever. - 2026-06-20: Merchant dashboard redesign, one-click data refresh, and automatic store currency. A first polish pass on the merchant-facing app, all $0 and dependency-free. Look & feel: a calmer, denser dashboard, teal on a charcoal board in dark mode, a clean blue-on-white light mode, a fixed left icon sidebar, and monitor/sun/moon theme icons; the app is now responsive on phones, and chart text is one consistent size. Sync now: because each store's warehouse is a built snapshot, newly added orders didn't appear until a re-sync, there is now a one-click "Sync now" on the dashboard (plus an opt-in nightly re-sync). The rebuild is atomic, so the dashboard stays up while it runs. Currency, the honest way: amounts now show the store's own currency symbol, captured automatically from Shopify at sync, never an invented exchange rate; if the currency isn't known yet, a bare number is shown rather than a wrong symbol.
- 2026-06-20: "Ask" is now a chat, and it shows the exact SQL. The Ask page is reframed as a conversation in the Plum palette: your question, then the analyst's receipted answer, and every answer now displays the exact SQL the deterministic layer ran (query, parameters, row count) beside the receipt id and hashes. The model only picks a governed tool; code writes and runs that SQL, and every number traces to it, so the whole process is visible, not just the result. The duplicate query-log table was removed from the Ask page (the Audit page remains its single home).
- 2026-06-19: Multi-store install proven end-to-end against a Shopify development store. Ran the full Shopify App Store install flow for real on a free Shopify development store, an engineering validation, not a customer: the store holds Shopify's synthetic test data, so any figures it shows are not real-merchant numbers. The path that had only ever been fixture-tested now works live: OAuth install → HMAC + signed-
stateverification → token exchange → a live GraphQL Admin API pull (version 2026-04) → a per-tenant warehouse build → trial start → the first-run onboarding page → a working dashboard, with every number receipted. Also bumped the Shopify Admin API client 2025-10 → 2026-04 (latest stable) and taught the production launcher to carry the app credentials. - 2026-06-19: First-run onboarding: "syncing" state + trial-days banner (D-030, Phase 4). Closed the two gaps a merchant hits before the dashboard, both $0 and zero-dependency. (1) First-run syncing page: a freshly OAuth-installed tenant is provisioned empty and built by a background first sync, so until that lands its database has no schemas and the dashboard used to 500. A gate in
http_api(merchant session + warehouse not built) now renders a branded, honestsyncing_page: zero-JS<meta refresh>so the dashboard appears on its own when the build commits, a CSS-only spinner with a reduced-motion opt-out, and a plain "didn't finish" message (no auto-refresh) on a failed sync. The gate keys on build existence, not "sync running", so a later re-sync of a built tenant never flickers (build.py is atomic). Sync state is tracked viashops.set_sync(running|done|failed, started_at)fromsync.sync_tenant. (2) Trial-days banner:entitlements.trial_bannerrenders days-left (rounded up; "Last day" inside 24h) + a Subscribe CTA, bound into_layoutvia a_BANNERContextVar (same pattern as the theme var) and shown only for a trialing merchant session, silent for active subscriptions, expired trials (the entitlement gate shows the subscribe page), and the operator. Verified: newtest_onboarding.py(6), trial-banner tests intest_billing.py(+2), sync-status test intest_sync.py(+1), and the real-HTTPsmoke_sessions.pyextended to 21/21, a trialing session sees the banner and a second EMPTY-provisioned tenant returns the syncing page (200, not a 500) over the production path. Operatorsmoke_admingreen; C8 receipt-integrity suites pass on a demo warehouse (interception 7/7, fault_injection 24/24). - 2026-06-14: Multi-store foundation + Shopify App Store readiness (Phase 4, D-021–D-029). Turned the single-store product multi-tenant, built to the Shopify App Store's requirements: fixture-proven, no new dependencies. A five-expert panel costed the path (D-021); the build then landed the full spine: a tenant-routing seam (D-022) with db-per-tenant isolation (each store its own PostgreSQL database, zero SQL change, D-023); the Shopify OAuth authorization-code install flow plus the three mandatory GDPR/compliance webhooks, with constant-time HMAC and a stateless signed
state(D-024); per-tenant warehouse sync reusing the proven build path (D-025); signed-cookie merchant sessions so each store sees only its own data (D-026); a Shopify Billing subscription with a 14-day trial and an entitlement gate (D-028); and per-tenant receipt isolation so the Ask agent is fully tenant-scoped (D-029). A four-agent adversarial security review then hardened the OAuth/session/webhook surface: tenant scoping moved into the warehouse layer, the raw SQL API made operator-only, webhook destructive actions bound to the signed body, fail-closed auth (D-027). Verified by unit tests and real-HTTP smokes; the receipt-integrity gate stayed green. - 2026-06-14: Ask agent: facts-first answers + a dormant direct-API transport (perf, team design-debate). A live model-path question took ~43s; a measured breakdown showed ~98% was two sequential LLM round-trips (tool-selection 12s + interpretation 30.6s) while the actual warehouse/receipt/render work was 0.7s. Two changes, run through the full team cycle (design → Critic → implement → Frontend → independent verify); the Critic surfaced 5 must-fix items including a cross-request honesty hole in the Explain step (proven numerically safe by post-check scoping, but fixed anyway by deriving the question from the receipt, never the client form). (1) Facts-first staging:
ask()split intoask_facts(selection+execute → renders RECEIPTED FACTS immediately) andinterpret(receipt_ids)(model prose on demand, still under the full two-strike post-check). The admin Ask page shows the verified number first with an "Explain" button carrying onlyreceipt_ids; refusals and?nomodel=1show no button; an honest new placeholder distinguishes "not generated yet" from the deterministic "no model ran". (2) Direct Anthropic API transport inmodel._completevia stdliburllib(no new dependency), gated onANTHROPIC_API_KEYwith the subscription path as automatic fallback; non-200/parse/empty responses all map toModelErrorwith no key leakage. Verified on the subscription path: facts render first, Explain yields a PASS-postchecked interpretation, a bad receipt id is an inline error (not a 500), other pages + nomodel unbroken, canonical 11/11, replay MATCH, placeholder green. The API path is implemented but dormant pending Anthropic API credits (valid key, zero balance); once funded it is expected to cut a question from ~43s to single-digit seconds. - 2026-06-13: Added the
active_customersgoverned metric (D-007), via the team design-debate. A live merchant question, *"how many customers this month?"*, hit the Ask agent's honesty guardrail: no governed definition existed, so it refused withno_definitionrather than invent a number (the product working as designed). Closed the gap via the charter process: Data Architect proposed → Evidence Engineer critiqued (3 must-fix honesty issues: an orphaned guest-checkout caveat, an inaccurate LTV-distinction claim, an unlabeled live example) → Lead arbitrated (keptcomparable, droppedsegmentableas out-of-scope) → AI Engineer implemented → Evidence Engineer independently verified. The metric is a scalarCOUNT(DISTINCT customer_key)overgold.fact_sales(date_range + country filters; not additive; guest checkouts excluded by design: a count of *identified* customers). Served by the existingrun_metricscalar path with no tool-layer change (enums derive from the registry); the guest-exclusion caveat ships in every receipt (_STATIC_CAVEATS), not just the doc. Verified live: the original NL question now answers 2 (model path, claude-haiku-4-5) with a receipt that replays MATCH; deterministic path also 2/MATCH; direct warehouse cross-check 2 (5 of 9 lines are guest, excluded); canonical 11/11; placeholder check green. - 2026-06-13: Shopify connector went LIVE against a real store (W5-2, D-006). Added the
--source shopify-livebuild target and the live auth path: the Dev Dashboard's client_credentials grant (legacy staticshpat_custom apps are retired on new stores).ShopifyClient.from_envnow mints a 24h Admin API token fromSHOPIFY_CLIENT_ID+SHOPIFY_CLIENT_SECRET(staticSHOPIFY_ACCESS_TOKENstill wins if set);load.load_bronzetook anode_iterfactory so the live path reuses the fixture path's bronze DDL/INSERT and the identicalshopify_silver.sqlunchanged. Ran live againstpfvew0-0x.myshopify.com(RSD): full pipeline built clean, 11/11 canonical checks, receipted Asktotal_revenue = 32,250replayed MATCH (build8a9628f2). Found & fixed a real defect live: the store's products had no SKUs, so keying products on SKU alone left every line unattributed (v_product_performanceempty; NULLproduct_numberfailing the canonical PK check). The mapping now keys products on SKU when set, else the variant GID (always present); fixtures carry SKUs, so the fixture path stayed byte-identical (revenue 210,417; 11/11: regression-verified). Honest gaps (live store was tiny, 5 products, 1 guest order, 0 customers): multi-page pagination, customer/LTV/cohort/repeat analytics, and the incremental cursor remain fixture-proven only. Live warehouse left as the repo's current build. - 2026-06-13: Shopify connector shipped, contract-first and fixture-proven (W5-1, D-006). Introduced the connector contract (
app/connectors/contract.md): every source maps tobronze.<source>_*→ canonical silver entities (silver.customers/silver.products/silver.order_lines, engine-agnostic) → the EXISTING gold star schema + 6 analytics views, unchanged. Refactored the demo as connector #1: the CRM+ERP integration that lived in the gold dim views moved down into a new02b_canonical_demo.sqlwith byte-identical logic, and gold was re-pointed at the canonical entities. Equivalence proven, every audited number reproduced EXACTLY: total_revenue 29,356,250; 27,659 orders; 18,484 customers; 295 current products; repeat 62.86%/37.14% with 77.02% of revenue; LTV avg 1,588.10 / median 272.00 / p90 4,825.70 / max 13,294; top customer AW00012132 (France) 13,294; 33/33 demo quality checks; C8 gate 6/6. New source-agnostic05_canonical_checks.sql+run_canonical_checks.py(PK uniqueness,sales_amount = quantity × price, referential integrity, gold non-degeneracy): 11/11 on the demo build. Connector #2, Shopify (GraphQL Admin API 2025-10, incremental sync byupdated_atcursor, Relay pagination): finishedclient.py(injectable transport;from_envraises a clear error when credentials are absent; 14/14 unit tests over parse/pagination/edge-cases), version-dated synthetic fixtures with guest-checkout, partial/full refund, deleted-product, and incremental-re-sync edge cases,load.py→bronze.shopify_*(raw JSONB), andshopify_silver.sql→ canonical entities (dedup by latestupdatedAt; refunds NOT netted). Build integration:build.py --source demo|shopify-fixture(default demo, unchanged + identical counts);meta.build_infonow records thesource.--source shopify-fixtureobserved: full pipeline to all 6 gold views; bronze 41/25/121 as-received → silver 40/25/317 after dedup; gross revenue 210,417 EUR; guest checkout → 15 NULL-customer lines, deleted product → 1 NULL-product line, both keeping revenue; 11/11 canonical checks; a receipted ask (plum_ask.py --no-model "total_revenue") returned 210,417 and replayed MATCH, proving the D-003 receipt machinery works unchanged over Shopify-shaped data. Demo source rebuilt afterward: repo default state is the demo warehouse. Liveness (verbatim, D-006): tested against API-shaped fixtures; not yet run against a live store. The live GraphQL path (SHOPIFY_SHOP+SHOPIFY_ACCESS_TOKEN) is config-only and unexercised: no live call has ever been made, and there is no--source shopify-livetarget. No new dependencies (stdlib + psycopg; urllib for the unexercised live path).check_no_placeholders.pypasses.
Week 4
- 2026-06-13: Public marketing site shipped (W4-2): five static pages, evidence-first, every claim paired with its reproduction command. New
site/: plain HTML + one handwritten CSS file (ink/paper with a ledger-green accent), zero JS, no CDN, no framework, relative links only (renders fromfile://). Pages: index (one-liner, three honest sections, and a demo block embedding the VERBATIM output of a real model run ofplum_ask.py "What was total revenue?"made today, total_revenue 29,356,250 receipted asrcp_c60c017f43c346328383448a66088735, post-check PASS, replay verdict MATCH, labeled "real output, demo dataset"); product (Ask flow, receipts + replay verdicts table, the real governed funnel refusal quoted from a live run, the five existing admin pages, honest absences and not-yet-supported metrics, architecture section); evidence (9 claim→proof rows, each with the exact command, what to observe, and the freshly observed result: ask→replay MATCH, doctored-receipt FAIL, C8 gate 6/6 re-run today, quality checks 33/33 re-run today, fingerprint3e02a4b72bbd66a222d0ff9dc808e7fbreproducibility, cross-engine equivalence, 10/10 independent validation, governed refusal re-run today, smoke_admin all-green re-run today; plus a "what we don't claim" section and dataset provenance: public training dataset, 2010–2014 bike sales, not real merchants); changelog (GENERATED from this file by new stdlibsite/build_changelog.py, one source of truth, IA rule 1); about (solo builder + AI agent team stated plainly, the category teardown story in three sentences without naming names, contact mailto). No pricing page (pricing undecided: nav carries an early-access mailto CTA instead); zero invented traction anywhere (no merchant counts, testimonials, logos, or lift percentages, the footer on every page states pre-launch status). Observed: newsite/check_site.py, 201/201 checks PASS (all files exist, every internal link relative and resolving, nav + footer + pre-launch statement on all five pages, no placeholder patterns in any site file, index demo block contains the receipted 29,356,250, evidence has ≥5 claim rows citing the real commands, changelog page generated with Week 1 + Week 4 entries);check_no_placeholders.pyover app/ passes. No new dependencies (stdlib only). - 2026-06-13: Thin admin UI shipped (W4-1, D-005): five server-rendered pages on the existing stdlib server, zero new dependencies. New
app/server/admin_ui.py(+app/server/static/admin.css): semantic HTML rendered in Python, no framework, no Node, no CDN tags, no client-side JS;http_api.pyroutes/admin/*and/static/*to the newhandle_uiseam (form-encoded POST bodies on those paths), all JSON routes untouched. Pages, every number queried live at render time with the canonical SQL fromapp/docs/metrics.md, each KPI footnoted with its governed metric id: /admin Overview (total_revenue 29,356,250; orders 27,659; aov 1,061.36; repeat_rate split 62.86%/37.14% with 77.02% of revenue; customer_ltv_summary avg/median/p90/max; build-identity panel frommeta.build_info+ dataset window); /admin/revenue (v_revenue_monthly table + dependency-free server-side inline SVG bar chart, 38 bars from the same rows, partial-month caveat quoted); /admin/cohorts (v_cohort_retention as a 38×38 shaded matrix, sparse cells honestly labeled "zero, not missing", governed cohort definition quoted); /admin/ask (form POST →model.ask, blocking, with a visible "calls the model" note; renders the EXACT renderer output, RECEIPTED FACTS code-rendered with receipt ids + hashes, INTERPRETATION visibly tagged "model prose - not receipted", governed REFUSAL tagged distinctly; per-receipt model-free replay command; post-check status line;?nomodel=1deterministic toggle viaask_without_model; recent questions from the audit log, newadmin.ask/admin.pageaudit actions); /admin/audit (last-N table with?action=prefix filter, refusals/rejections highlighted red, build_id column). Honest absences: Funnel/Experiments/Insights get NO pages or mock UIs, the nav carries one greyed non-link "Funnel - requires event data (see docs/metrics.md §Not yet supported)"; every page footer: "Every number on this page traces to a governed metric. Receipts replayable via plum_replay." Observed: newapp/scripts/smoke_admin.py, 31/31 deterministic checks PASS (all five pages 200 with real values incl. 29,356,250, 2013-07 = 1,371,595, cohort 5.23%, live fingerprint3e02a4b72bbd66a222d0ff9dc808e7fb; nomodel Ask returned RECEIPTED FACTS + receipt id; JSON /health and /views unbroken);--with-modelrun: real Ask "What was total revenue?" over the HTTP form path →rcp_7c61617284374555a3f03ef7add35c75, post-check PASS (attempts 1, regenerated no, fallback no), replay verdict MATCH. C8 gate re-run after the change: all 6 suites PASS; placeholder check passes. No new dependencies (stdlib only).
Week 2
- 2026-06-13: Model layer wired via Claude Agent SDK (W2-3, D-004); five real end-to-end transcripts, all numbers receipted, 6/6 gate still green. New
app/agent/model.py: the ONLY module importingclaude-agent-sdk(new dependency, 0.2.100, flagged per D-004; rides the local Claude Code subscription, no API key; model defaulthaiku, overridePLUM_MODEL/--model). The model does exactly two things: proposes tool calls as JSON (executed solely throughtools.ToolSession.call, closed schemas, registry SQL, receipts, audit; rejections go back once, then a deterministic governedrefuse) and writes INTERPRETATION from ONLY the code-rendered RECEIPTED FACTS text, enforced bypostcheck.enforce(two strikes → deterministic fallback). New CLIapp/scripts/plum_ask.py(--no-model,--json,--model); audit extended withmodel.proposal,model.proposal_invalid,model.repair_round,model.refuse_fallback,model.interpretation(actor "model"). Observed, real model runs (claude-haiku-4-5-20251001): "What was total revenue?" → total_revenue = 29,356,250 receipted (rcp_68b2cff53e4c4a92a93f1d8bff155ba2), post-check PASS, replay MATCH; "Compare June and July 2013 revenue" → 1,642,948 vs 1,371,595, derived delta −271,353 / −16.52% receipted, post-check PASS, MATCH; "Which countries drive revenue?" → country breakdown top-10 (US 9,162,327 … segment_sum 29,356,250) receipted, PASS, MATCH; "Show me the conversion funnel" → governed not_supported refusal quoting metrics.md, after an audited live repair round (firstrefuseproposal lackedmetric_id, rejected, model corrected); adversarial "Why did revenue drop in July 2013?" → model chose compare_periods + July-by-category breakdown, prose stayed inside receipted numbers and stated "the data shows what changed but not why", PASS, both MATCH. Post-check caught a real strike: an earlier run's first draft quoted caveat-only numbers (19; 4,992) →answer.postcheck_violation→ regenerated draft clean (answer.regenerated); that run's date-filtered total 29,348,616 was itself fully receipted and replays MATCH, no number ever shipped unreceipted.render.NO_MODEL_NOTEre-worded (model wiring is no longer "the next task"). C8 gate re-run after the change: all 6 suites PASS; placeholder check passes. - 2026-06-13: Defect C8-1 resolved: replay grades ERROR on malformed receipts; C8 gate re-run PASS. AI Engineer remediation of the MAJOR filed by the Evidence Engineer (W2-2).
app/agent/replay.py:_gradeis now a never-raises wrapper around_grade_strict, structurally malformed receipts (missing keys, wrong types, non-dict top level) gradeERRORwith reason "receipt malformed - refusing to grade (<exc>)" instead of crashing out ofreplay_file/replay_all; execution-field reads moved out of the re-execution try so a missingexecutionblock reads as malformed, not "re-execution failed". Two tests added toapp/tests/test_c8_fault_injection.py: 4 malformed shapes all grade ERROR naming malformation, andreplay_allsurvives a corrupt store member (corrupt file ERROR, every other receipt still graded, observed MATCH). Observed:run_c8_gate.pyre-run all 6 suites PASS (fault-injection 9 tests, was 7);ask_demo.pyregression PASS (3x MATCH, doctored copy FAIL, refusal audited); placeholder check passes. No open BLOCKER/MAJOR remains (C8-2..C8-9 MINOR/LOW, backlog), gate verdict after re-run: PASS (resolution + re-run log appended toapp/docs/evidence.md). - 2026-06-13: C8 verification gate built + Amendment C1a implemented (W2-2); gate verdict FAIL on one open MAJOR. Evidence Engineer deliverables:
replay.pyverdict filter re-based on the content fingerprint per Amendment C1a (fingerprint differs → STALE; fingerprint match + re-execution hash match → MATCH withrebuilt_since: truewhen build_id differs; fingerprint match + hash mismatch → FAIL; tamper/internal/derived failures FAIL on any build). New gate runnerapp/scripts/run_c8_gate.py+ four suites inapp/tests/(scratch receipt store, live warehouse):test_c8_interception.py(receipt sql/rows asserted as the SAME OBJECTS run_query executed,is, not==, across the full registry matrix: 9 metrics + filtered variants, 4 compare templates + filtered, 3×4 segment templates ± date_range),test_c8_fault_injection.py(five doctoring layers, each with its exact predicted failure signature: rows-only → internal_result_hash+receipt_hash; rows+result_hash → receipt_hash+reexecution_hash; fully coherent → reexecution_hash only; doctored derived under a coherent hash → derived only; deleted caveat → receipt_hash only; plus C9 collision-is-error),test_c8_renderer.py(stored = shown under in-memory mutation; per-turn binding: ToolSession.receipt_ids is the renderer's and post-check's only source, a true number from another turn is a violation),test_c1a_verdicts.py(all three C1a branches incl. a REALbuild.pyrebuild mid-suite: build_id flipped, fingerprint identical3e02a4b72bbd66a222d0ff9dc808e7fb, pre-rebuild receipt → MATCH + rebuilt_since, NOT STALE; coherent lie after rebuild → FAIL, no longer hidden by STALE). Observed: all 6 suites PASS, c8_interception 17.1s, c8_fault_injection 3.2s, c8_renderer 2.2s, c1a_verdicts 6.4s, quality_checks 33/33 1.1s, no_placeholders 0.2s; 22 tests green on first full run. Gate verdict nonetheless FAIL: adversarial pass beyond the prescribed tests filed 9 defects (C8-1…C8-9 inapp/docs/evidence.md), and C8-1 is MAJOR,replaycrashes (KeyError) instead of grading ERROR on structurally malformed receipts, so one corrupt file killsplum_replay --all. Also filed: canonical num_str crash >28-digit ints / silent rounding of >28-digit decimals / negative-zero asymmetry (C8-2..4, MINOR), coherent-edit residual risk, replay does not cross-check the audit-mirrored receipt_hash (C8-5, MINOR), missing filename↔receipt_id binding (C8-6, MINOR), lowercase "1.5m" magnitude bypass in the post-check (C8-7, MINOR), identical/overlapping compare periods accepted (C8-8, LOW), renderer binding is call-site discipline (C8-9, LOW). Defects filed, not fixed (C8 discipline: remediation is the AI Engineer's); gate re-runs after C8-1 lands. No new dependencies (stdlib unittest + psycopg). - 2026-06-13: Ask-agent tool layer shipped (W2-1, D-003 C1–C7 + C9).
app/agent/: deterministic machinery only, model wiring is the next task.canonical.py+registry.py(inherited from the interrupted run) verified against the live warehouse and kept unchanged: canonical-JSON idempotence across dump/load, ROUND_HALF_UP pinning, double-execution hash stability oncustomer_ltvtop-10 confirmed (C2). New:receipts.py(receipts built from the same objects passed to/returned byrun_query; build identity frommeta.build_info, C1; full-receiptreceipt_hash+ per-turn binding token, C7; uuid receipt ids persisted toapp/data/receipts/, collision = error, C9),tools.py(6 tools + explicitrefuse; closed stdlib schema validation, no$ref, top_n ≤ 50 everywhere; EVERY call and EVERY rejection audited with params/result_hash/definition_version/build_id, C6),postcheck.py(critique rules R1–R7: magnitude-marker + number-word bans, presence- scoped allowed set incl. top_n and date components, 0/1/2-dec ROUND_HALF_UP roundings, abs-matching, NULL-safe, two-strikeenforcehook: C3/C4),render.py(structural RECEIPTED FACTS / INTERPRETATION contract, facts code-built from the stored receipts, every interpretation line prefixed, C5),replay.py+ CLIscripts/plum_replay.py(model-free re-execution; MATCH/STALE/FAIL/ERROR per C1, verdicts audited). Observed (scripts/ask_demo.py, live Postgres): total_revenue 29,356,250; compare 2013-06 → 2013-07: 1,642,948 → 1,371,595, derived delta −271,353 / pct_change −16.52; top-5 country breakdown (US 9,162,327 … France 2,643,751, segment_sum 27,151,692); replay of all three receipts 3× MATCH; doctored copy (rows + both hashes coherently recomputed) → FAIL on reexecution_hash;refuse(session_funnel)quoted the metrics.md "Not yet supported" text verbatim and audit-loggedtool.refusewith build_id + turn_id; 6 rejection classes (schema, registry filter, unknown value 'Narnia', out-of-window, top_n 51, missing export id) each audited astool.rejected. Post-check: faithful prose passed; "1.37M" / "twenty" / foreign number flagged (R1, R2/R3). Warehouse rebuilt (build f7572a6d → b2aa969c, fingerprint unchanged3e02a4b72bbd66a222d0ff9dc808e7fb) → all 6 stored receipts re-replay STALE with "fingerprint and numbers unchanged" detail. No new dependencies (stdlib + psycopg); placeholder check passes. C8 (named Evidence-harness tests) is the milestone gate owned by the Evidence Engineer: fault-injection shape demonstrated in the demo, interception + renderer tests pending.
Week 1
- 2026-06-12: Service layer ported DuckDB → PostgreSQL (D-002 complete); build identity on /health (D-003 C1). Backend port of the last D-002 remainder:
server/warehouse.pynow runs on psycopg 3.3.4, duckdb import gone from the server modules. Read-only is double-enforced: every service connection opens withdefault_transaction_read_only=on+statement_timeout=10000as libpq startup options (fresh autocommit connection per call), and the unchanged SQL gate (single statement, comment stripping, SELECT/WITH-only, forbidden keywords incl. SET/RESET, 1,000-row cap) keeps it unflippable from SQL. Server-sidestatement_timeoutreplaces the DuckDB interrupt watchdog: observed killing the 60,398² cross-join count at 1.08 s on a 1 s budget and 10.12 s on the default 10 s budget (QueryCanceled→QueryTimeout). Params now bind via psycopg%splaceholders through the unchangedrun_query(sql, params)signature (D-003 C1);list_views()/get_view_sample()resolve againstinformation_schemawhitelisted to the gold schema (9 views); NEWget_build_info()returns the latestmeta.build_inforow, exposed onGET /health(build_idf7572a6d-…, fingerprint3e02a4b72bbd66a222d0ff9dc808e7fb).audit.pyuntouched;http_api.pyonly gained the health build block.smoke_server.pyported + extended: 21/21 checks PASS, all 13 previous (v_repeat_rate still one_time 11,619 / 62.86% / 22.98% of revenue vs repeat 6,865 / 37.14% / 77.02%; DROP,SELECT 1; SELECT 2, comment-wrapped DELETE all 400 + audited) plus: health returns the real build_id matchingmeta.build_info;%sparam round-trip;SET default_transaction_read_only=offand an INSERT both refused 400 by the gate and logged asquery.refusedwith the exact SQL; belt-and-braces direct-driver INSERT on the service connection refused by Postgres itself ("cannot execute INSERT in a read-only transaction"); statement-timeout kill verified in-suite. No new dependencies (stdlib + psycopg already in place per D-002); placeholder check passes. Docs: service-layer section added todocs/warehouse.md. - 2026-06-12: Warehouse ported DuckDB → PostgreSQL (D-002), cross-engine equivalence proven. Data Architect port to local PostgreSQL 18.4 (database/role
goolify, DSN viaPLUM_PG_DSN, driver psycopg 3.3.4). SQL files ported in place:01_bronze.sqlis now schemas + DDL only (CSV loading moved tobuild.pyas client-sideCOPY ... FROM STDINvia psycopgcursor.copy(), no server-visible paths needed);02_silver.sql(try_strptime→ newmeta.try_yyyymmdd()NULL-on-invalid plpgsql function,INTERVAL '100 years', explicitROUND(numeric)::intprice derivation);03_gold.sql(date_diff('month',…)→ calendar-month boundary arithmetic,MEDIAN/quantile_cont→percentile_cont(…) WITHIN GROUP,::numericdivision for AOV where Postgres INT/INT truncates);04_quality_checks.sql(one derived-table alias).build.pyrewritten: single-transaction rebuild (DROP SCHEMA … CASCADE → SQL files → COPY loads), per-layer counts + timing, and NEWmeta.build_infobuild identity (build_id uuid, built_at UTC, per-CSV source row counts, per- relation row counts, total revenue, deterministic md5 content fingerprint, the T8 receipts identity, replacing file mtime).run_quality_checks.pyported (read-only psycopg session). Observed equivalence vs the audited DuckDB baseline, every number exact: bronze 18,494 / 397 / 60,398 / 18,484 / 18,484 / 37; silver identical incl. 18,484 deduped customers and 37 birthdate NULLs (16 future + 21 age>100, DQ-1 rule ported); gold dim_customers 18,484, dim_products 295, fact_sales 60,398 with 0 NULL dimension keys; views 38 / 430 / 18,482 / 130 / 2 rows (repeat rate 62.86% / 37.14%, repeat revenue share 77.02%); total revenue 29,356,250 over 27,659 distinct orders; LTV avg 1,588.10 / median 272.00 / p90 4,825.70 / max 13,294; date-filtered revenue 29,351,258 (delta 4,992 = the 19 NULL-date lines); spot checks (2013-07 = 1,371,595 / 1,875 / AOV 731.52; cohort 2013-01: 325 / 17 / 5.23%; Mountain-200 Black- 46 = 1,373,454) all identical. Quality suite on Postgres: 33/33 PASS. Two consecutive builds verified re-runnable with identical content fingerprint3e02a4b72bbd66a222d0ff9dc808e7fb(distinct build_ids).goolify.duckdbretained on disk untouched as the frozen audited baseline. Docs updated:docs/warehouse.md(engine, rebuild, build_info),docs/metrics.md(engine ground rule; 0 canonical SQL blocks changed, 3 formula descriptions updated to the Postgres function equivalents). Still on DuckDB pending their own port (D-002 scope, not this task):server/warehouse.pyread-only service andscripts/smoke_server.py(they keep working against the frozen baseline file). - 2026-06-12: Defect DQ-1 resolved: implausible birthdates cleansed. Data Architect fix for the defect filed by the Evidence Engineer (T4/T6).
02_silver.sqlnow nullserp_cust_az12birthdates implying age > 100 at load (bdate < current_date - INTERVAL 100 YEAR), symmetric with the existing future-date rule. Observed effect after rebuild: 21 bronze rows nulled (the 15 flagged pre-1924 births, 1916–1923, plus 6 born 1924-01-01 to 1926-06-11 caught by the age boundary); silver min birthdate now 1926-09-07; total silver birthdate NULLs 37 (16 future + 21 old, 0 in bronze). Quality suite re-run: 33/33 PASS (was 32/33). No gold metric affected: birthdate feeds no analytics view; all row counts unchanged (silver.erp_cust_az12 still 18,484). Documented inapp/docs/warehouse.mdand resolution note appended to DQ-1 inapp/docs/evidence.md. - 2026-06-12: Quality checks + independent metric validation (T4 + T6). Evidence Engineer pass over the warehouse. T4: 33-check quality suite (
app/warehouse/sql/04_quality_checks.sql, runnerapp/scripts/run_quality_checks.py) covering silver PK duplicates/NULLs, untrimmed strings, standardized-value audits, date validity/order, sales = quantity × price, gold surrogate-key uniqueness, and fact→dim referential integrity. Result: 32 PASS, 1 FAIL, the failure is defect DQ-1: 15 pre-1924 birthdates (1916–1923) pass throughsilver.erp_cust_az12uncleansed and undocumented; filed to the Data Architect, check left red on purpose. The two documented source gaps (19 NULL order dates, 7 CO_PE products) are allow-listed at their documented magnitude. T6: every claimed gold metric independently recomputed from silver tables with separate SQL, total revenue 29,356,250; 27,659 distinct orders; dates 2010-12-29 → 2014-01-28; dim_customers 18,484; dim_products 295; fact_sales 60,398 (no join fan-out); repeat 37.14% of customers / 77.02% of revenue; LTV avg 1,588.10 / median 272 / max 13,294 over 18,482 customers (2 excluded, ids 27039 + 16322, all-NULL order dates), 10/10 claims CONFIRMED, including full-table diffs of v_revenue_monthly (38/38 months) and v_cohort_retention (430/430 cells) plus raw-row traces (June 2013 revenue 1,642,948; cohort 2013-01 month 3 = 19 of 325; customer AW00012301 LTV 13,294 across 13 lines; Mountain-200 Black- 46 revenue 1,373,454 traced to bronze). Full audit trail with verbatim queries:app/docs/evidence.md. - 2026-06-12: Service layer skeleton + audit log shipped (T9).
app/server/:warehouse.py(read-only query service, single-SELECT gate with comment stripping, forbidden-keyword + multi-statement rejection, 10 s interrupt watchdog verified killing a 60k³ cross join at 1.02 s, 1,000-row cap,list_views()/get_view_sample()resolved against the catalog so no raw identifier interpolation),audit.py(append-only JSONL atapp/data/audit/audit.jsonl, ts/actor/action/ detail + optional sql/result_summary + open kwargs so agent tool-calls and diffs slot in without migration),http_api.py(stdlib-only localhost JSON API: /health, /views, /views/{name}/sample, /audit, POST /query; all routing in a framework-agnostichandle_request(), the stdlib shell is the throwaway half pending the T8 framework decision).app/scripts/smoke_server.py: 13/13 checks green on a free port, real query returned both v_repeat_rate segments (one_time 11,619 / repeat 6,865 customers, 22.98% / 77.02% of revenue),DROP TABLE x,SELECT 1; SELECT 2, and a comment-wrapped DELETE all refused with 400 and logged asquery.refusedwith the exact rejected SQL. No new dependencies (stdlib + existing duckdb 1.5.3); placeholder check passes. - 2026-06-12: Governed metric definitions shipped (T7).
app/docs/metrics.md: the semantic layer the Ask agent cites in every answer. 8 metric sections (total_revenue, orders, aov, revenue_monthly, cohort_retention, customer_ltv + customer_ltv_summary, product_performance, repeat_rate), each with canonical id, plain-language definition, exact formula, grain, source lineage (gold view → silver tables), tested canonical SQL, real caveats, and a query-verified example value (all examples computed against the built warehouse on 2026-06-12; e.g. revenue for 2013-07 = 1,371,595). Documents the two reconcilable revenue totals (29,356,250 all lines vs 29,351,258 date-filtered; delta = the 19 NULL-date lines' 4,992) and verified order-month additivity (every order has exactly one order date) and that the 7 NULL-category products have zero sales. Includes an honest "Not yet supported" section (session_funnel, promo_performance, connector-dependent) and a versioning rule: definitions change only via dated entries inspec/decisions.md. - 2026-06-12: Warehouse shipped (T3 bronze/silver + T5 gold). DuckDB medallion warehouse at
app/warehouse/goolify.duckdb, built bypython app/warehouse/build.pyfromapp/warehouse/sql/01_bronze.sql,02_silver.sql,03_gold.sql(~2 s rebuild, verified re-runnable across two consecutive builds). Bronze: 6 raw tables (18,494 customers / 397 products / 60,398 sales lines / 18,484 + 18,484 ERP rows / 37 categories). Silver: same 6 cleansed (customers deduped to 18,484; 19 invalid order dates nulled; 23 sales amounts and 12 prices repaired; 200 broken product end dates recomputed via LEAD). Gold: star schema views, dim_customers 18,484, dim_products 295 (current versions), fact_sales 60,398 with zero NULL dimension keys, plus 6 analytics views (v_revenue_monthly 38 months, v_cohort_retention 430 cohort cells, v_customer_ltv 18,482 + v_customer_ltv_summary, v_product_performance 130, v_repeat_rate: 62.86% one-time / 37.14% repeat carrying 77.02% of revenue). Total observed revenue 29,356,250 across 27,659 orders, Dec 2010 – Jan 2014. Session funnel and promo views NOT built: dataset has no event/promo data (documented as connector-dependent inapp/docs/warehouse.md). - 2026-06-12: Project scaffolded.
app/structure, Claude Code hook guardrails (.env read guard, action logging), no-placeholder CI check, and the demo store dataset seeded (6 CRM/ERP CSVs, 5.2 MB). Decisions D-001 locked: DuckDB local-first, bootcamp dataset, name "Plum" (spec/decisions.md).