Različica
Katera koda teče na tem strežniku in kaj je v njej.7c26a2c
Zgrajeno 24. okt 2026, ta strežnik teče pred 2 h.
Zgodovina sprememb
7c26a2cBatch 5 of the 2026-10-23 review (docs/21-review-2026-10-23.md). With it all 60 findings are fixed; the unverified leads remain. - Narrowing then widening a profile no longer mails tenders again as new: a removed match leaves its dates in a new match_history table. - A tender being bid on stays the firm's: FOLLOWED counts a bid, and a narrowing never removes its match. - The quiet note tells only what is true, to readers whose matches are on. - The exclusion offer counts what the archive counts; restoring a group answers it. - CPV search ranks every match before the cut. - Weekly readers: /e-posta and the first mail say Monday. - Inbox shortcuts stand down under the palette, assistant and menu; the live list unfolds its bar and searches composed words. - Certificate dates, CSV formula cells, long text files. - Questions-backfill marker defaults TRUE; furs dry run opens no run row; retry budget leaves room for the next night; an improving check is not new trouble. - Writes from older browsers judged by Origin. - The demo deploy checks the tree is the commit asked for; no unread build argument. - Ten blind guards made to see; the assistant prunes nightly and caps files per chat under the chat's lock. 44 defects put back, 44 caught. The suite passes in 8 chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
ef48320Batch 4 of the 2026-10-23 review (docs/21-review-2026-10-23.md). Live: a NUL byte in any request was an unhandled DataError. From any account, /razpisi?q=a%00b, /ujemanja, /narocnik, /profil/cpv and POST /profil each answered 500, wrote a server_errors row and took the box DEGRADED with an operator mail. main.NoNulBytes cleans every path and query string; routes_app.parse_form is the one form parser. The assistant (latent, no key yet): - F1 critical: a question whose row cannot be written is refused; a NUL failed the INSERT on purpose and skipped both daily caps. - F2: a failed preview rolls back, proposal ids are UUIDs only, the row is settled on every way out. - F3: every cited passage keeps its source; no stand-in sources. - F4: no transaction is held while the model thinks (ask.release). - F5: each call's spend is written to the running row. - F6: only a turn that asks for no tool is the answer. - F7: an old report link opens its own answer or nothing. - F8: 0 in a cap switches questions off; unknown models price at the dearest card; Haiku 5.5's long-prompt price. - browser-scripts F6, blind-guards F2, leads L1, L2, L5. 28 defects put back, 28 caught. The suite passes in 8 chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
436d4bdFrom the 2026-10-23 review (docs/21-review-2026-10-23.md, batch 3), each guard checked by putting the defect back: - Reading an uploaded paper runs on a thread: one 0.2 MB upload held every other request, /healthz included, for 5.5 s. - A firm's own Word or Excel paper is refused when its XML would unpack past 20 MB, read from the zip directory before parsing; a 150 KB file took the reader past 835 MB. Tender packs stay unbounded (real bills of quantities reach 178 MB of XML). - An upload's size is counted as it arrives, not after Starlette has spooled it to disk, and a firm may hold 500 MB in all, 50 MB on the demo (the owner's numbers). - The hourly demo reset deletes the demo account and firm and seeds them afresh, so a visitor's name, excluded groups, mail settings, bids, inbox and activity no longer survive it. - Reading a paper on /profil no longer replaces a typed tax number with the only other firm the paper names. - The demo worker mounts the firm's files it reads nightly. - The inbox no longer promises a crawl "jutri ob 7.00" on the weekly demo, nor mail the demo never sends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
09310ffEight reviewers, each reproducing its own findings, over the 157 commits since 029e866. 60 findings in docs/review-2026-10-23/, ranked in docs/21-review-2026-10-23.md. Fixed here, each guard checked by putting the defect back: - A TED correction no longer logs its assumed 23:59 as the deadline's hour, or a same-day pair as a move (429 such entries on production, 89 mailed). scripts/fix_assumed_hours.py mends the log, mailing nothing. - A link to a download no longer leaves the loading skeleton up for good (V koledar, the calendar export, Prenesi): theme.FILE_PATHS, checked against every route that sets a Content-Disposition. - A bare % in the address no longer breaks the page's scripts. - Browser error reports are counted on their own line and never alarm, decided by the owner: the endpoint is forgeable. - A failed tax-register retry no longer mails "ne deluje". - Days a reader sees are Ljubljana days: the mail's reminders and date, and the same UTC-day shape in nine other modules, now banned by a scan. - The mail's counts decline; one wording for a lapsing paper. - Deploys: a failed migration restarts the worker (production and demo), the nightly window is asked again before the worker stops, a gap-mode swap whose new container dies puts the old one back, an empty backup is not "verified", and the swap probe counts each request once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
3fc021bAsked by the owner: does the phone have everything the desktop has, especially the right click. A parity audit of all 49 signed-in pages found them at parity except around the menu, which is the only way to several things and which no phone user was told about. Measured in Chrome with touch before the fix: the menu opened at the finger with the page live around it, the dismissing tap also opened the row underneath, lifting the finger sent a click through, its rows were 35px, a sideways move did not cancel the press, and a long press in a text field opened it. Now a bottom sheet over a scrim on a phone, 44px rows on touch, the lifting click cancelled, movement either way cancels, text fields keep their own long press, and the browser's own long-press preview and selection are off on rows and record links only. A phone is told: the hint has a long-press line, the overview guide a long-press step, and the shortcuts list a "Dolg pritisk" row. The assistant launcher and account chip are 44px on touch. tests/test_touch_menu.py runs the real script under node: 12 defects put back, 12 caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
391abe7The owner asked for a nicer link preview and chose the design from two rendered options: the site's dark ground, the page's headline, and the morning-mail example beside it. /za-gradbince draws its own headline at /og/za-gradbince.png; only named pages are drawn. Every word comes from the page and the mailer (landing.share_spec, mock.mail_examples, digest.subject_for), and the picture's address hashes every string it draws plus a design version, so platforms cannot keep the old picture. Every public page now has a description of at most 160 characters: the main page's is the owner's choice and replaces 210 characters that every preview cut, the call page uses its own lede, and the sign-in page (where a bot lands for a shared tender link) had none. Also fixed: the step-down headline repeated "sploh ni na TED." instead of reusing HEADLINE[1], which tests/test_share_image.py caught; it shipped in the previous commit. 8 defects put back, 8 caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
6336202An audit of every claim on the public pages, asked for by the owner and checked against production: - The hero's sample report named a real tender with the wrong buyer, the wrong deadline and clauses cited to a file it does not have, under "Naročilo je resnično". It is three invented examples now, taking turns every seven seconds; buyers are kinds, not names, dates count from today, the tally is counted from the strip. - "Vsak dan ob 6.00" (the mail goes at 06:33-07:10) is "Vsako jutro". - The headline "Skoraj polovice" sat over 39 %. It is "Več kot polovice razpisov sploh ni na TED" over the last twelve months (61 %), counted daily with the construction page's figure, stepping down below half. - The documents stat counts files read, not links; the run stat counts crawl nights, not every job; SL1, the ZJN-3 threshold wording, "180 strani", nightly OCR, the closed-pilot copy, 30 vs 15 minutes, the walkthrough's ticks and the footer's licence line are corrected. - Eight feature cards for what already runs on production. - The mail example's tenders no longer name real municipalities, and the message sits in an inbox frame whose subject and sender come from the mailer; its fade ends in the mail's own paper. - On a phone the sample report's caption was clipped by 34px; it is not. Every new rule was checked by putting its defect back: 18 caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
c212b13Three reports from the owner, measured in Chrome at 1280, 390 and 320px. - The four-states section read as static. The cards now arrive one after another and each icon draws itself (measured with getTotalLength, held undrawn only while its animation runs, skipped with reduced motion). Each card has an example line, wording chosen by the owner, and the intro says the states work today for the ZJN-3 exclusion grounds while conditions from tender documents are still being added. - The contact block's ticks sat 6.5px above the middle of their first line; now 0.6px, as the hero's. - The footer's small print ran about 200 characters a line at 1280px with the copyright glued to a sentence. It has a 680px measure and the copyright its own line. tests/test_landing_details.py: eight defects put back, each caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
653d9bbProduction's .env now points at smtp.hostinger.com:465 with the info@lixum.agency mailbox, replacing the gmail.com relay. No code changed. The api was swapped with rollout.sh (0 failed of 96 probe answers) and the worker recreated; a test from production arrived with DKIM, SPF and DMARC passing for lixum.agency. Records the settings in .env.example, the two .env traps in CLAUDE.md, and the change in the handoff and checklist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXJiF1Rv2BhsgftiQTyQXv
ff3442eRecords the day's last three builds (v429 to v431) with their commits, what was offered and is still the owner's call (shorter wording for the construction page's pill, pages sent without their code comments), and that this note was pushed without a ship because it changes no page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
c7118e4Reported by the owner. As a flex row, each run of text in the hero's pill was its own item, and on every phone under 412px each wrapped inside itself: "SLOVENIJA · / DANES 119 ODPRTIH / NAROČIL", 57px tall. On one line it needs 357px at 13.5px; a 390px phone has 350px of room and a 360px one 320. Under 412px the pill takes 11px, the product's step for small capitals, and is one line from 360px up (32px tall). Under 561px it flows as text with balanced lines, so where a pill cannot fit (320px phones, and the longer pill on /za-gradbince) it wraps into two even lines instead of running past the screen. Measured on both pages at nine widths: nothing wider than its screen. Three planted defects were each caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
aaa6961Asked by the owner to test the landing page on mobile and desktop. Lighthouse on the live page: performance 92-98 mobile and 100 desktop, accessibility 89 and 93. A probe that switched each suspect off in turn on the live page (4x slower CPU for a phone) found what a score does not show, and the owner chose from the measured list: - The hero's live dot pulsed box-shadow, which only the main thread can paint: 2,886 ms of main-thread work in five seconds of the page sitting still on a phone, 825 on a desktop. The same ring on a pseudo-element's transform and opacity: 5 and 3 ms. A test now holds every sheet to it: an animation that never ends animates only transform and opacity (the microphone's pulse, which runs only while dictating, is the argued exception). - The two glows stand still: their slow drift made the GPU draw on every refresh while nothing happened. - The header's first scroll read waits for a frame, instead of forcing a full layout while the page is still being parsed. - Accessibility 100 on both: the logo link carries the name (its word is hidden on a phone), every public page has one <main> (the auth pages and the 404 included), the sample report's title is no longer an h4 under the h1, and the links inside the footer's sentence are underlined. Lazy layout of the off-screen sections was built and taken out: on a phone a jump to the FAQ came to rest 389px past it, and once the rest was fixed it saved 6% of the load's work and no blocking time. Measured old against new on one server, alternating: idle main thread 2,886 to 5 ms (phone) and 825 to 3 ms (desktop); load main-thread work 475 and 181 ms less (medians of six rounds); blocking time unchanged within the noise. Nine planted defects were each caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
3b3f0caAsked by the owner whether the site works at every window size. 49 pages at
14 sizes (320x568 to 2560x1440) were clean: nothing wider than its screen,
no error. The tender rows were not, on a tablet and in a window with the
side menu out:
- My own regression from this morning: "Nizke gradnje · po naslovu" made
the line under the title too long. Phone rows for construction were
20-40px taller than the rest; at 768 and 1024px the buyer's name
vanished from them.
- Older and bigger with production's rows, nearly all of which say
"z dokumentacijo": the buyer vanished from half of all rows on an iPad,
and at 901px with the menu out 13 of 20 rows showed none. Only the buyer
could shrink, so a long line squeezed it to nothing and then cut the
last tag mid-word ("TED in Porta").
The line now hides what does not fit, whole and from the end: the buyer
claims 10em first, the tags follow in order (region capped at 12em,
source, lots, documents) and the kind of work comes last, the owner's
choice. The phone's stacked row is one definition applied twice, on a
phone's screen and wherever the list is narrower than 880px (the first
container query here); it leaves the trade off, also the owner's choice.
Its actions spanned one grid row, not two (`1 / -1` in a grid with no
explicit rows), which stretched the date's row: phone rows drop from 246
to 176px on Ujemanja and 156 to 134px on Vsi razpisi.
Between 900 and 1100px the header sheds what the phone sheds: at 901 the
search box was a 26px empty box and "Zajem ne teče" wrapped out of the
bar. The landing page's corner bracket stood 2px past the screen from 721
to about 943px.
The pre-ship sweep also opens every page on a tablet both ways up and
fails a row that leaves its buyer no room or cuts a tag at its edge:
8 failed loads against the old code, 0 of 392 against this, 123 s.
Fifteen planted defects were each caught.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t1529556From the pilot-trade research: 1,351 of 2,272 construction tenders a year (59%) carry only a general code, so every construction firm was shown every one of them, about 155 a month for a road builder. - Each construction tender has a kind: nizke gradnje, visoke gradnje, inštalacije, zaključna dela, or unsorted. From the main code where it names one, else from the title; the card says "· po naslovu" when the title decided it (wording chosen by the owner). Measured first: the title rule agrees with the code on 84% of the coded tenders it sorts and leaves 16% unsorted. - One definition, construction.kind_sql, read by the card, a "Vrsta gradnje" filter on both lists (and the export and monitors), the matching and the new page. No per-cent sign in its SQL; OFFSET 0 keeps it from being flattened back into 500 folds a row (280-330 ms to 27-35 ms over the open feed on production). - A profile choice, "Katera gradbena dela izvajate?", narrows the matches and the morning mail; nothing ticked narrows nothing, the unsorted stay unless unticked, and Vsi razpisi always shows everything. /ujemanja asks a construction firm that has not chosen. - /za-gradbince sells to construction firms (headline and address chosen by the owner). Today's counts are taken as the page is drawn; the share only on the portal is the last twelve months, the owner's choice, counted once a day on a thread because grouping fifteen months of notices takes about two seconds: 2,019 of 2,265 (89%) on production. Every figure falls silent without a database. Linked from the footer and the sitemap. A test maps every monitor filter to its search keyword and fails a caller that forgets one; the fold badge derives its filters instead of typing them. Seventeen planted defects were each caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
cbe82f1Records where the research lives, what it recommends, the product finding about general construction codes, and what still waits on the owner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
40bf979The tender page and the reading room have shown every publication's files since 2026-10-11, and everything that lists tenders still asked about the notice in its row alone. Measured on production: 7 of 621 open tenders held their only readable files on an earlier publication, so they lost the "z dokumentacijo" tag and fell out of its filter, and the right-click menu counted only a correction's own files on every corrected tender. - procedure.holds_files is the one SQL answer, for the card (tag and the "Dokumentacija prebrana" rung), the filter, the mail's reading-room link (three queries) and the tour. procedure.files_of counts for the menu and its chip: two statements for a page of tenders, held by a test to what versions and newest_copies decide for one. - The four lookups that find a tender's publications have one spelling, ingest.db.family_candidates, shared with _FAMILY. A TED copy borrows nothing, as on its own page. - Warm on production over all 621 open tenders: 28-39 ms before, 41-75 ms after; only the 23 tenders with nothing readable on their own notice ask their family. A scan fails on any query in api/ that asks about one notice's files; nine planted defects were each caught. Still per notice, deliberately: /vsebina and the question box's retrieval. Also: the handoff notes no longer say the laptop has an AI key, or that the lixum.agency certificate cannot renew; every certificate on the box was re-measured, and tenderwatch.lixum.agency's renewal (about 23 October) is noted to check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
bd65908From a comparison with RC-LIXUM-ERP's copilot, which has run for real and been measured. Everything that needs no key is done, so switching on is a key plus one exam: - Its own model, apart from extraction: ASSISTANT_MODEL (claude-sonnet-5-5) at ASSISTANT_EFFORT (medium), the ERP's exam result, and a price card per model; an unknown model prices as Opus 5, never as free. - The instructions and tools cached for an hour, the files and one moving mark on the newest tool results for five minutes. - A question opens its row first and holds $0.40 of the day under a lock per payer, so parallel questions see each other; past that ceiling, one last call with tool_choice none answers from what was gathered. - A cut-off answer and a refusal say so. Every way out settles the row with its outcome, cost and duration; a failing tool rolls back instead of failing the rest of the question; calls time out at 120 s. - /stanje shows the assistant's model and daily limits. tests/test_signed_out.py derives every route from both routers: public ones are listed with a reason, and every other one must turn a signed-out visitor away. 84 checks, nothing leaked; a planted missing check is caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
a3cd774"Vsi razpisi" and "Ujemanja" waited for Enter while the search box at the top searched as you typed. Now, 300ms after the last keystroke (nothing for a single letter; an emptied box asks for everything), the list is fetched with the same GET the form would send and only the results region and the count sentence are replaced. The field being typed into is outside that region, so it is never replaced under the caret. A choice applies at once, Enter searches in place and puts a phone's keyboard away, and the address follows. A failed answer or a sign-in page is shown the plain way. Also: searching inside Skrito no longer drops back to the matches (the archive was not a form field), and the phone's filter button is spelled "razvrščanje". Measured in Chrome at 1280px and 390px: no reload, focus kept, one request per word, 75 to 130ms an update. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
6bd2e93A paperclip opens the composer row, and a file can also be dropped on the open assistant or pasted into the question. What is kept is what we made of it: a JPEG of at most 1.15 megapixels with no EXIF, a document's text with its page numbers, or a scan's first five pages. It is scoped to the reader in every WHERE clause, joins its chat in the same transaction as the exchange that names it, and goes with the chat. Every question resends the conversation's files, so a chat holds five at most and they carry a prompt-cache mark; llm.usd now bills cache writes and reads. The drawer posts raw bytes, never a FormData. On a phone the files sit in one row that scrolls sideways. Measured in Chrome at 390px and 1280px, drawer and full screen: no control covers another. Not tried against the real model: there is no working key on either box. Wording chosen by the user. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
b8bfab7- /racun: the site link kept its words on a phone, so the card no longer reads as a lone chevron and "Odjava" at opposite ends. - The microphone left both field-of-work boxes on /profil (owner's call). - `gap` had no rule outside `.fine.gap`: ten elements, including the mail preview on /e-posta, sat flush against the block above. - The reading room laid a 390px phone out 615px wide: the grid's align-items:start carried into the phone's flex column. - /dokazila: the paper link sat in the card above it, and "Izbrisi" sat 11px in from its row. - scripts/sweep.mjs fails any page wider than the phone at phone size; with the reading room fix taken out it failed exactly that one load. - test_version asserted the text `--count HEAD`; it now asserts that the header counts the commit being shipped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
2a362ffSeeded references and staff read ['72413000' and '72212000'] because the old evidence._list stored a list's repr split on the comma; the writer was fixed, its rows were not. A row is rebuilt only when its pieces read back as a Python list of strings; codes must be eight digits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
5d57b00HEAD was read five times, so a docs commit made while a ship was building failed the final check against a live site correctly serving what had been packed, and skipped the demo and the push. A commit between the sweep and the archive would have shipped code no browser had opened. The commit is read at the top, handed to the sweep, and used for everything else. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
f938e31Drawn from production with the file's own query: two portal-only (SL1) notices and two TED controls, each with a word no search can blur, all open until 3-5 November. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
7016a98Recorded frame by frame in Chrome: the rail's padding snapped while each item's padding eased, so every click jumped the icons and the logo 6px sideways and drifted them back; and the subtitle wrapped onto two lines while the rail was narrow, so the menu dipped before rising and overshot on the way back. Both paddings now share one curve and the small capitals never wrap: icons and logo hold still on every frame, and the menu moves one way only. "Nadzorna plosca" sat 6.6px under the logo; it has 12.6px now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
7a5d2eeThe new UptimeRobot monitor reported the site down with HTTP 405 within the hour: its HTTP check asks with HEAD, and FastAPI registers only the method a route declares, so every page refused HEAD while visitors got 200. RFC 9110 requires GET and HEAD. A HEAD is served as the GET of the same path, headers kept and body dropped, by the outermost layer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
6790a03Asked for from real use: text describing what a screen does took space on every visit. Measured over 33 signed-in screens: twelve opened with such a paragraph, 24 to 224px of a phone. views.heading draws the title with the description behind an info icon: hover with a pointer, a tap on a phone, focus from a keyboard. Counts, status lines, the tender a page is about and Pod pragom's warning stay on screen as head-notes, and a sweep over every signed-in screen fails on any other paragraph under a title. Also: Shranjeno said "3 zaprtih" for "3 zaprti", and the right-click menu's shadow never drew (hsl() around a token that is a whole shadow list). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
8162a0aReported from real use: right-click opened too many options, the menu was too big, and after scrolling the rest could not be seen. Measured in Chrome: a tender on the overview gave 14 options in a 625px box on a 1366x657 laptop window, because every row also carried the page's own actions. A record now gives its suggestions and its own actions; anything else gives the page's. Row menus went from 9-14 options to 3-7. The menu is bounded at the window's height and scrolls inside itself; a page scroll closes it, so an unbounded menu hid what fell below the fold. Also records the owner's decision: no off-box backup copies for now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
ae2b1a7On 2026-10-19 fu.gov.si cut the first file four times and the fifth and last allowed ask finished it; every ask had moved the download forward. A download now ends after three asks in a row that kept no new bytes, the pause grows only with those, and twenty asks in all stay as a ceiling. Also: test_freshness typed "avg." or "sep." for a date 19 days back and went red when that became October; the month is worked out now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MBxPuMMeXuqGkZUjzRM6t
0d09728Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7da4696fu.gov.si cut the download short three nights out of four (16-18 Oct). Friday's three quick retries each asked for the whole file again, so on the 17th all three were cut and the box read BROKEN for 19 hours. - furs.download asks only for the missing bytes (Range + If-Range on the copy's Last-Modified), five attempts, and proves the archive by every member's checksum. Checked against the live server with a forced cut. - The scheduler's wait between nights asks again every 2 hours while the newest run of a retried source failed, with a budget counted from the run log that stays under the run-rate alarm. - status.failed_state grades such a failure by the age of the copy it still serves: OK while being asked again (no mail), DEGRADED after a day, BROKEN after two. /stanje shows it amber; the topbar pill leaves it out. TED and the portal are unchanged. - The register now runs after the morning mail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
4fbe6a3docs/19 item 13. api parses files strangers upload and worker OCRs packs from public portals; both now run as an account (uid and gid 10001) that owns the two storage folders and nothing else. /app stays root's. - The image creates both storage roots owned by the account, so an empty volume needs nothing. scripts/own_volumes.sh, run as root, hands over what the account does not own: production's 21,008 root-owned pack entries once, then nothing. Both deploys run it before anything stops and again after the swap, for what the old root api wrote meanwhile. - rollout.sh runs every command on the drain flag as root. docker cp writes the flag as root into a sticky /tmp, so the account could not remove it, and release_new does that after the old container is drained: set -e would have ended the swap with nothing in Traefik. Measured on a real container; the stand-in docker now models owners. - The demo deploy is scripts/deploy_demo.sh, run on the box; it was a file in the box's /tmp. tests/test_nonroot.py derives the roots, deploy scripts and mounts; all seven defects it guards were put back and caught. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
099fb84docs/19 items 12, 14 and 20, chosen by the owner, wording too. - main.same_origin_writes refuses a POST/PUT/PATCH/DELETE whose Sec-Fetch-Site is same-site or cross-site (chat., n8n. and demo.lixum.agency are the same site); none and no header pass, so RFC 8058 one-click unsubscribe still works. - The session cookie is __Host-tw_session wherever it is Secure (auth.session_cookie_name); signing out removes it under the prefix's own rules. The rename signs everybody out once. - The sign-in page says why a browser was signed out, read off its own stale cookie (routes_app.SIGNED_OUT). - The demo guard is default-closed and matched by route template across both routers: every write route is in demo.BLOCKED, ALLOWED or OPEN. The demo also refuses the profile picture and account deletion now. Traefik limits the demo to 8 requests a second per address, burst 20. Two test fixes: the browser-error 204 case now uses a header that still reaches the endpoint, and a new test holds the middleware's 403; and test TED numbers are eight digits, because six-digit draws collided with the 9,193 real 2026 numbers on the laptop about one time in a hundred. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5a44426buildx ends its prune on "Total:<tab>937.3MB" and the image prune on "Total reclaimed space: 0B"; reading only the second, the first real run reported 0B while it freed about 9 GB (43 to 52 GB free). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCBHfx8BMLLPPA9iMCWhzm
ce12554Nothing pruned it: Docker build cache was 14.3 GB, 13.1 GB of it reclaimable, and free disk fell from 59 GB to 43 GB in 17 days. After the live check, ship.sh now keeps the newest 4 GB of build cache (--max-used-space, shared with the ERP and the agency site, so never emptied) and removes dangling images. A failed prune is reported and never fails the deploy; SHIP_KEEP_CACHE=1 skips it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCBHfx8BMLLPPA9iMCWhzm
b7451f0A quiet link under the tax number on /profil and above the reference form on /dokazila, and certificate photos are now read. No model: tesseract and the registers we hold (api/scan.py). It reads only what it can check: a tax number by its check digit and the FURS register, a figure, date or subject only under a word that says what it is, each shown with its source line. Several firms on one paper are a choice for the reader. Nothing is saved by reading; a reference's paper joins the row only when the form is sent, and a paper read for the profile is not kept. Bounded: first page, 2,400 px, greyscale, 30 s, one read at a time, 20 a day per person, none on the demo. Measured on the VPS: 1-11 s and 72 MB a page; 5 of 6 photo-like pages read, 1 refused, 0 wrong. The multipart guard now sees a button's formenctype, and the activity label guard sees a kind passed as a constant. Hints on /dokazila are styled. Wording chosen by the user. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCBHfx8BMLLPPA9iMCWhzm
4f40450fu.gov.si dropped the connection partway through both taxpayer files on
2026-10-16 and served them whole a minute later. One cut took the box
BROKEN for the night and mailed the operator. furs.download now tries
three times (30 s, then 60 s apart) and checks that the archive opens;
an HTTP error status is still raised at once.
The per-source alert printed the age of the last success as the age of
the failure ("neuspešen pred 13.0 h", five minutes after it failed). It
now carries both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCBHfx8BMLLPPA9iMCWhzm768fdc5Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
4cff551Step 3 counted as done only once a region was ticked, so a firm working all over Slovenia could never finish it, was told on /ujemanja that its list was too wide, and saw "Regije izvajanja" printed as missing. An empty region list always matched everywhere; it could not say whether anybody had been asked. A »Vsa Slovenija« chip in front of the twelve regions records that answer (companies.all_slovenia); the step, the nudge and the papers read it. save_profile clears the regions when it is set, and it wins when both are ticked without script. Matching is unchanged. Reported by the user. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
5431984Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
96159bcThe last of the September cold-start items, chosen by the user: - the code search on a phone's first screen (y=1,567 -> 587); - every save on /dokazila names itself and the next step, and the save that finishes setup says so once; - step 3 is regions alone; - the reference form has the code search and stores every chosen code; - /stanje says whether registration is open; the file delete is 44px on a touch screen. And sharing and voice: - a shared link shows a picture drawn from the landing headline (/og.png, og: tags on every page); - dictation in the two long boxes keeps listening across pauses (the ERP's 4.181 and 4.183, ported). Found in Chrome on the way: an open results list was painted over by the next section, because each block on a signed-in page is its own stacking layer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
9018d9dCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
49dbc2eCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
96b7ae1Hiding three tenders from one group offers to stop showing that group; nothing changes until the reader says so, a "no" is remembered, and an excluded group can be brought back from the hidden tenders. A match the profile no longer makes now leaves the list instead of staying until the tender closes. After two weeks with nothing to send, one note shows the open tenders just outside the profile. The mail can come weekly, on Monday. A link in a sent message signs the reader in through a button, for a week, and a new password ends it. A calendar app can subscribe to the deadlines through a secret address that a new one replaces at once. The browser-error test no longer builds its marker from hex that the endpoint is right to scrub. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
dc5131fA company's name key is now unique among companies rather than among all taxpayers, and the contracts it finds leave out any written to an "s.p.". Measured on production, 2,625 company keys were shared only with sole traders, so the first rule found nothing for every one of them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
072e0a1Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
e5979a4A tax number fills the company's name and registration number from the tax administration's daily list of taxpayers, loaded nightly into tax_subjects. The lookup saves nothing and keeps everything the reader set. The firm's own past public contracts, found in the buyers' published lists by the firm's own name, are offered as references to tick. A name that more than one taxpayer shares is not used, and a contract shared with other firms shows only the firm's own part of the supplier's name. A certificate uploaded on its own becomes its row, named after the standards it states, with the file attached; the expiry it states is offered with a button that writes exactly that date. An undated reference is no longer called older than five years. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
358685aCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
d2279feSaving the profile lands on the matching tenders, says it saved, and names the next setup step as a link. It went to ten legal questions instead. The CPV picker answers a trade word with what buyers filed under when their own titles used it, from an index of title words built off the request path; labels then order by how often buyers use each code. The right first result went from 12 to 32 of 40 trade words, and "mizarstvo", "krovstvo", "gostinstvo" and "slikopleskarstvo" find their work. The divisions are read off the code. "Describe your work" asks the same search: its first suggestion was the firm's trade on 16 of 16 descriptions, and only that one is ticked. A new account gets its tenders by mail the same evening, once the profile is saved and the address confirmed, and never before the address is. The matches nudge names only the fields that narrow the list, the list headlines decline their count, and the four company fields that change no match are folded on the profile. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
93f656eCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
fb4ca8dscripts/sweep.py serves HEAD from a fresh worktree against the laptop's database and drives Chrome over the DevTools protocol (Node 22, no dependency): every GET both routers serve, signed out and signed in, at desktop and phone width. It fails on an uncaught exception, a console error, a same-origin 400+, the page's own 5xx, or a refused session. ship.sh runs it before anything is sent and refuses on a failure; SHIP_SKIP_SWEEP=1 skips it, loudly (docs/19 item 11). The page list is derived from the routers with argued exclusions, and tests/test_sweep.py runs the real runner against pages that each break one way. First run: 47 pages, 188 loads, 0 failed, 64 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
fd48d88A listener, the first script in every page's head, posts uncaught errors and unhandled rejections to /api/browser-error, which writes server_errors with method JS through the same writer server errors use, so ingest.status escalates them. Ported from the ERP (docs/19 item 10). Every row takes the box DEGRADED, so little reaches the table: only our own origin's errors, five per page and one per message, the ERP's noise list, one row per message a minute, six per visitor in ten minutes, thirty an hour in all, and only same-origin requests. A row holds the route template, the browser family and a scrubbed message. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
e605468- A delete on /dokazila asks on the row (views.del_form), in the assistant's own words: "Izbrišem to referenco?" Izbriši / Obdrži. Without script the first press still submits. - Removing evidence, removing a file and resetting a password are written to the activity log; the first and last had labels and no writer. A removal by another tenant writes nothing. - The offline page's cache name is a hash of the page and its icon, so a changed page reaches an installed phone. - Both font files are preloaded, so the first č no longer swaps fonts. - The search palette's footer collapses until there is a count. - /koledar's figure reads "Rokov za oddajo ali prijavo". Measured in headless Chrome at 1280 and 390: every question fits on one line inside its row, no horizontal scroll, no console error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
7a2b092send_daily_digests mails every confirmed account with something to say, and a dev database keeps leftover test accounts, so the run's count read 3 on the first run of a day and 1 on every later one. The test now counts messages to its own address, and still asserts that nobody at all is mailed twice in one morning. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
63d1509Measured read-only on production first: as it stood it would have changed 966 rows, every one wrongly. 963 were rows merged with their portal notice, whose deadline is the portal's by design, and 959 of those would have had TED's assumed 23:59:59 put over the portal's real hour, 421 of them open tenders. It now never writes a merged row and refuses a guess over a known hour on the same Ljubljana day, through db.keeps_known_time, which a test checks against Postgres evaluating _KEEP_KNOWN_TIME itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fbyh63HkrAZqTxxW86wh5
fdb6620The calendar's "Rokov za oddajo" figure counts application deadlines, fix_ted_deadlines would write TED's assumed hours over merged rows, and the search palette's footer is an empty strip until a search runs. None is urgent; the second is a warning not to run that script as it stands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
1c5fff3Shipped 3bc2066 to production and the demo with no gap and linked both databases: production 302 links, the open feed 648 to 627 (all 21 that left TED copies), the demo 26 links and 576 to 567, exactly as simulated. Checked over HTTPS on the live demo from both rows of a merged tender. The link_twins docstring now gives the time measured on the real write. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
3bc2066When the buyer gave TED no procedure reference, the portal notice and its TED copy shared nothing the procedure key reads, so the feed showed both: 19 open tenders twice on production, and 2 more kept open on the copy's end-of-day deadline after the portal's real 10:00 one had passed. notices.procedure_link joins a TED copy to the one portal procedure whose pack URL (the portal's own file address, which TED repeats) it carries, for the same buyer. A copy never wins the collapse, is not part of the tender's history, and the source line now speaks for the whole tender. refresh_current_notices links first; the key has one Python spelling. Simulated on production first: 302 links, none ambiguous, the open feed 648 to 627, and 17 tenders that said "Portal" now say "TED in Portal". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
4555b89Production and the demo run v383 (0b4aa2e). Records the correction's numbers on both databases, the duplicate-rows join approved as the next task, and the 941 closed portal tenders whose stored hour is an hour early, left for the user to decide (the correction was run filtered to application deadlines only; do not run it unfiltered until then). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
0b4aa2eFound by reconciling the feed against both sources on the user's question
"are we getting any tenders?". We were: every TED and portal notice in the
window was held. But a concession, a restricted procedure, a partnership
or a dynamic purchasing system asks firms to APPLY first, and its notice
states a deadline for requests to participate and no tender deadline. The
portal ingest read datumDoPonudba alone, so those notices had no deadline
and the feed never showed them; TED fell back to its generic `deadline`,
which is BT-13, the date for questions, so TED copies left the feed one to
two weeks early. On production that day 13 of the 17 procedures open for
applications appeared nowhere, and 324 portal notices were affected.
notices.deadline_kind ('tender' | 'request') says which date `deadline`
holds; `deadline` keeps the date a firm must act by, so sorting, the feed,
urgency, reminders and the calendar work unchanged, and every surface that
shows the date names an application deadline "Rok za prijavo" (the user's
choice): feed and dashboards, the tender page, the morning mail, the
calendar and .ics files, the export, the assistant.
- A change of kind is not a move, in all three change detectors, so the
correction mails nobody a deadline that never moved.
- Every select list that takes a deadline takes its kind; a test derives
that from every SQL string, with no exemptions.
- An application deadline stays an agenda BID entry, named by Entry.word:
the kind is in each exported event's UID.
- scripts/fix_request_deadlines.py corrects stored rows with a plain UPDATE
(report by default, --apply writes), asking TED for BT-1311 by
publication number; scripts/fix_ted_deadlines.py shares the new rule.
Not fixed, and left to the user: a portal notice and its TED copy with no
shared reference are two tenders to the feed; 7 open ones already showed
twice and about ten two-stage ones join them.
Every guard was checked by putting its defect back (30 mutations).
Full suite: 3,515 passed, 17 skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp2dea5baAsked for 2026-10-14: emails in light or dark, and light is better for mail. The digest declared `light dark` and repainted itself inside prefers-color-scheme, so a phone in dark mode turned it dark with no way to choose. Now /e-posta has "Videz sporočila" (Svetlo / Temno, the theme toggle's own words), stored in users.digest_theme, light by default. Light declares `light only`, which the clients that honour the hint respect; dark applies the palette unconditionally. Making dark a choice showed it had never worked: every colour is inlined for Outlook, so dark reaches an element only through a class, and the links, the logo, the footer, each card's date line and the change and credential cards had none. They kept light-theme navy, grey and beige on a dark ground, measured in a screenshot. Each now carries the class for its tone (emailtheme.urgency_class beside urgency_colour), and a test walks the mail for any inlined colour dark cannot reach. Also the preview's sample change used type "deadline", which nothing writes, so its label printed the bare key; it is "deadline_moved". Each new guard was checked by putting its defect back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
bfa9780Reported from real use. Measured in Chrome on every signed-in screen at
1280 and 390 first: three rows of buttons sat 0px under the text above
them -- the tender header, the tender page's empty-state call to action,
the save on /e-posta -- because `p{margin:0}` is the base rule and a
button row brought no gap of its own. The primitive now gives any row
that follows something 18px (the header's own designed gap), except
beside a heading in .page-title. All three measure 18px; nothing else
moved. Two local hand-fixes (an inline 12px, a dead class="gap") go.
The search was the one place that printed keyboard shortcuts: Ctrl K,
an Esc keycap and three key hints. They are gone; the keys still work.
Its backdrop was built on the text colour, so dark mode laid a 42% white
wash over the page. It now paints nothing, which was the user's call.
tests/test_button_rows.py holds both; each guard was checked by putting
its defect back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp8d7dee9Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
1187e03Reported from a real device: ask the assistant something, close it with its X, and the overview behind it stayed a grey loading skeleton until a reload. CHROME_JS covers the page on every followed link and form submit; the assistant sends its question by fetch and cancels its own submit, and the skeleton's listener never checked, so it waited for a navigation that was never coming. Escape hid the bug because Escape also restores. The guide offers' "Ne, hvala" did the same. Both paths now read defaultPrevented after every listener has run (a document listener registered later can cancel after the skeleton's own). Measured in headless Chrome: both defects on the old code, neither on the new, and real links and form posts still show the skeleton. tests/test_skeleton.py runs the real block under node and fails three ways on the old code. Full suite 3,451 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
be6ffa9Production and the demo run 557c28a. The ship itself was the one-time first swap (5 of 18 probe answers, the old container predated the healthcheck); a second swap with no code change lost 0 of 113 answers on production and 0 of 66 on the demo. The demo's deploy procedure now ends in the same swap. Disk measured and left alone: 47 GB free, the shared build cache 9.1 GB reclaimable. Correction: 557c28a's message says "six swaps" locally; it was five. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
557c28a`up -d api` stopped the old container before the new one answered, and every ship lost about three seconds of requests (6 of 9 probe answers on 46036bc; docs/19 item 8). scripts/rollout.sh now starts the new container beside the old one, waits until Traefik's own API routes to it, drains the old one by failing its healthcheck on purpose, and stops it only once Traefik has let go. Measured against a local Traefik v3.7.9 under GETs, kept-alive 64 KB POSTs and requests in flight: 0 failed answers in six swaps, and 0 when a broken build was backed out. - scripts/healthcheck.py: the api's Docker healthcheck, /healthz only, failing while the drain flag exists; --ready ignores the flag. - Both compose files: the healthcheck on api, and no host port (two containers run side by side during a swap). - A change to any traefik.* label is refused (Traefik drops a router two containers define differently: 404 for everyone); ROLLOUT_ALLOW_GAP=1 swaps one container at a time instead. - ship.sh swaps the api and restarts the worker after; a failed swap leaves the previous build serving and puts the worker back on it. - tests/test_rollout.py runs the real script against a stand-in docker that records what Traefik would route to after every command; eleven defects were put back and each failed a test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
221b5aaThree ships today (v372 to v374), the web-service checklist and the cross-project findings, what waits on the owner, and the measured deploy gap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
365f7dcThe probe ship.sh now runs through Traefik counted 9 answers during the restart of 46036bc, 6 of them not 200: about three seconds with no backend. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
46036bcThe owner asked for the other Lixum projects to be read for findings that apply here (docs/19-cross-project-findings.md) and approved items 1 to 7. - Opening the unsubscribe link no longer unsubscribes. Corporate mail scanners open every link, so a customer could be unsubscribed by their own filter. The GET shows a button; the POST and RFC 8058 one-click act. - The waitlist and /klic limit counted every visitor as one: behind Traefik the socket peer is the proxy (measured live, all traffic from 172.18.0.3), so the sixth lead of any hour was refused. It reads the forwarded address. - A new failing check during an ongoing alert is mailed (fingerprint of check names and states, never counts). - The health check fails instead of waiting: a connect timeout on every connection, lock and statement limits in status.gather. Migrations give up after a 15 s lock wait. - ship.sh refuses during a running ingest or the nightly window (SHIP_ANYWAY=1 overrides), stops the worker before migrating (the deploy deadlocked twice on 2026-10-08), and prints "shipped." only when /healthz's new X-Build header shows the new commit through Traefik with one backend. Tests: test_unsubscribe, test_lead_limit, test_timeouts, test_ship (runs the real script with a stand-in ssh and curl), plus test_alerting. Each guard was checked by putting the defect back. Full suite 3,413 passed, 17 skipped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
1ad1d47From the checklist written the same day (docs/18-web-service-checklist.md); the owner approved everything on it except pinning dependencies. - The font is served from our own origin. Figtree came from Google Fonts, which sent every visitor's IP to Google without consent. The CSP now names no third-party origin; measured in Chrome, our host is the only one any page contacts. - An unhandled error gets a Slovenian page with a way back, and a row in server_errors (route template, exception class, our file and line; nothing personal). One in 24 hours degrades ingest.status, so it reaches /stanje and the operator mail. - The nightly backup archives the uploaded certificates beside the dump, and records the night only after the archive is proven to hold exactly what the volume holds. The off-box script ships the archives too. - Setting a password ends every session issued before it (a signed epoch in the cookie). /racun can now change the password; this device stays signed in, every other one is signed out. Old cookies read as epoch 0, so the deploy signs nobody out. - A new account's address is confirmed by a mailed link before any morning mail or test send goes to it. The account works at once. Existing accounts are counted as confirmed, once. Wording chosen by the owner. - /.well-known/security.txt. Tests: six new files; full suite 3,389 passed, 17 skipped, in 8 chunks. Each new guard was checked by putting the defect back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
425e3bbAsked for 2026-10-13. Most of it was learned first by RC-LIXUM-ERP on the same owner's iPhones and recorded there with measurements. - The status bar is opaque (`default`, was `black-translucent`). Translucent sizes the web view short by the status bar on a Dynamic Island iPhone, 59pt of dead ground under the tab bar, and draws the clock white over the light theme. - iOS launch images, drawn on request by Pillow from the same mark as the icons: 19 devices x 2 orientations x 2 themes, sizes whitelisted. - An offline page and a service worker that answers only a GET navigation the network failed, and stores only that page and its icon. Verified in Chrome across a real server outage, and executed under node in tests. - The assistant's report opens inside an installed app (an iPhone opens target=_blank in a sheet with its own cookies, i.e. signed out), with its own back button. - format-detection telephone=no; no orientation lock; one definition of the page ground for the status bar, splash and offline page; long-press shortcuts derived from the tab bar. Tests: tests/test_homescreen.py (23); full suite 3,315 passed in 8 chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ju21gY4QyhgvEjwFSKMNNp
721bd40Production and the demo on v370; what is waiting on the user (a model key, and which model the assistant should run on); the local key measured as not usable, correcting section 1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
0bac8c7Seen on the live demo after v369: the tour's dock and card are fixed bottom-right at z-index 80 and 81, above the assistant's panel at 71, so "Vodič po sistemu" sat on the send button in the drawer and in full screen. They step aside while the assistant is open and come back when it closes. The test reads both z-indexes, so it is re-asked if either side moves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9c4dc3cFound on the live demo after v368 shipped. The demo's middleware refuses a blocked POST /pomocnik with a 303 to a screen carrying the reason in napaka, and fetch follows redirects: the drawer got a whole HTML page, read it as an empty stream and said the answer could not be read. It now reads the reason from the redirect, and treats any non-NDJSON reply as a rendered turn. The node harness answers the way the middleware does; the test fails on v368 with the exact message seen live. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ad162baPorted from RC-LIXUM-ERP 4.219-4.229, asked for as the same level of sophistication, "also the extended view, mimicking Claude's web dashboard". - Answers carry markdown tables, one chart, choice buttons and follow-up chips (fenced chart/choices/next blocks, lifted out and validated in api/answerparts.py; a bad block is dropped, never printed). A tender is linked only if a tool returned it in this conversation. - Saved conversations (assistant_chats, api/chats.py): 90 days, read only by the person who had them, scoped in every WHERE clause; a stranger's id answers 404. The server appends each exchange itself, so the history the model hears is history this product wrote. Multi-turn memory, last 12 messages. - The drawer widens to a full-screen workspace with the conversation list (new chat, search, day groups, delete that asks on the row), a greeting by first name and the screen's questions as cards; on a phone the list slides over the conversation. - Kopiraj / Porocilo / Excel under every answer: an A4 report page and an .xlsx built from the saved answer, no model call; text cells are written as text, so a tender title cannot become a formula. - A live line of what the loop is doing (NDJSON, Content-Encoding identity so gzip does not hold it), a Stop button, today's date in Ljubljana in the prompt. Starting, deleting, reporting and exporting are in the activity log, never the question. Fixed on the way: - The drawer posted FormData (multipart) to routes that parse urlencoded, so from a browser every question arrived empty and every proposal confirm had no token. URLSearchParams now; a node test runs the real script and parses what it posts, and a scan refuses a bare FormData anywhere. - Its scrim and shadow used tokens that do not exist and never rendered. - Two document reads in one question numbered passages [1] twice. - Five activity kinds (pomocnik, dokazila, izjava, ponudba, racun) showed on /dnevnik as raw keys; a guard derives the kinds from every record() call. - The full-screen panel stopped 15px short at the scrollbar gutter. Wording chosen by the user. Not ported: attachments, the section picker, the token meter, the ERP's model switch. Tests: tests/test_assistant.py (50); full suite 3,286 passed in eight chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
8bcdb15Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
b843ed3test_an_old_copy_under_a_live_intake_shows_both_dates put the last intake run "eight hours ago" and asserted the page says "danes". Between midnight and 08:00 Ljubljana that run is yesterday, so the page correctly said "včeraj" and the test failed. Found by a suite run at 00:04. The run is now halfway between Ljubljana's midnight and now, which is today at every hour, checked across a whole day and both daylight-saving days. Also: the full suite as eight separate processes took about five minutes, recorded in CLAUDE.md, and a handoff section at the top of docs/13-next-session.md for the 2026-10-11 session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
b0edc12Chosen by the user from three drafts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
eac93c9The foot of the tender page now shows up to five other open tenders in the same CPV group (four digits, the evidence block's rule) and an overlapping NUTS-3 region, soonest deadline first. A tender that states no region gets the group across Slovenia and the rule line says so. Nothing is scored or ranked by suitability; the rule is printed under the heading and a capped list says "5 od N". Measured on production: 316 of 622 open tenders get a list, median 2, 0.8 ms. Also corrects a CLAUDE.md entry whose "found building the above" had come to point at the wrong entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
e209ea1Each file is labelled Navodila, Obrazci, Popis in predračun, Tehnične zahteve, Načrti, Pogodba, Dodatki in popravki or ESPD, from its name, and a row of filter links narrows the list to one role with counts over the whole pack. A file whose name says nothing is Drugo, not forced into a role: measured over production's 4,429 open-tender files, 24% are. The file the buyer's conditions section was found in is marked "Začnite tukaj" and is now where the reading room opens, instead of the first previewable file, which the extraction spike recorded is reliably a drawing. With no conditions found, the pack's only instructions file is used; two candidates is a guess and gets no mark. A filtered list of Word forms opens the first one, drawn, instead of saying nothing can be shown. The matching order and the reading order are separate constants, so the ESPD can win a name without leading the filter. The word "ponudba", not the stem, after the stem moved 94 instructions files into the forms. And "datoteka" now has one spelling, in emailprefs: two modules had copies and the reading room's own count printed "2 datotek"; an AST scan holds it. Every new test verified by reintroducing its defect. Wording chosen by the user. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
6ae0785One caption in the header card: "Naročnik je podobno naročilo objavil že 16. jan. 2026: »...« (izida ne poznamo).", or "Ponovljen postopek. Prejšnji poskus ..." when the title says it is a repeat. The earlier attempt's outcome comes from the award record when we hold it -- bids and price, never who -- and is otherwise said to be unknown, because unknown and failed must not read the same. An earlier attempt is the same buyer, a notice someone could bid on (one with a bid deadline, so no second copy of the biddable-type list), 2 weeks to 18 months before, outside the tender's own correction family, and a title at trigram similarity 0.9, or 0.5 when the title says ponovljen / ponovitev. Measured on production: 122 of 622 open tenders, 30 with a known outcome; at 0.7-0.9 the sampled matches were different purchases. Searching by title cost 500 ms a tender. Narrowing by buyer first, through the trigram index's own expression, makes it 9 ms median and 20 ms p95, and a test derives that expression from the schema so the query cannot drift off the index. Every new test verified by reintroducing its defect. Wording chosen by the user. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
d6b3c32In the buyer's verbatim conditions section, the phrases a contractor looks
for are marked where they stand and listed once above the text: counts of a
capacity ("najmanj eno (1) referenco"), euro amounts, periods ("v zadnjih
treh letih"), standards (ISO, EN, S.BON / eS.BON, bonitetna ocena, IZS) and
named roles ("odgovorni vodja del"). Regular expressions, no model; it
marks and never reads, assigns no state and claims nothing about the firm.
When the text names a certificate the firm holds, it is shown beside the
text with its expiry, and a lapse before the deadline is said in words. It
matches on the standard's number, so ISO 9001:2015 is ISO 9001; no number,
no match. The firm's credentials come through the evidence bridge, scoped
in the WHERE, and a database test holds one firm's certificate never
reaches another firm's page.
Narrowed by measuring over production's 546 sections: counts of anything
marked the statutory "najmanj dve kršitvi" and "vsaj 4 m" in specs, and
small amounts the statutory "do 50 EUR". After: 367 sections carry a mark,
median 2 phrases. eS.BON was found unmarked on a real page and added.
Escaped before marked. Every new test verified by reintroducing its defect.
Wording chosen by the user.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3809b567Under the tender's header, folded to one line that leads with the change most likely to alter a plan: "Popravek 2 · 5. okt. 2026 · rok za oddajo prestavljen na 20. okt. 2026 · in 1 prejšnji popravek". Opened, each correction shows the buyer's own description, the deadline and question deadline moves, and the files that are new, re-published at a different size, or re-published at the same size (counted, not named). The portal carries the buyer's description in popravekOpis, on 83 of the 174 corrected open tenders; it is quoted verbatim and escaped. Dates are compared as Ljubljana days, the rule ingest.db._moved already holds, so an assumed end-of-day time is never reported as a move. An orphan correction lists no files as new, having nothing to compare with. Measured on production, read-only: a box on 173 of 174 corrected open tenders at 4.8 ms p95, and every computed date checked against the buyer's own words agreed. Every new test verified by reintroducing its defect. Wording chosen by the user from options. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
6bf9658Today's red suite came from a test that committed synthetic notices and kept them, which another test then picked up as "the most recently closed tender". Fixing that one test does not stop the next, so the suite now counts every table only the pipelines write, at the start of the session and at the end, and fails if any count moved in either direction: grew is a leak, shrank is real data deleted. The set is derived in tests/corpus.py from the INSERT INTO targets of ingest/ and extract/ minus those of api/, so requirements -- which api/qualify.py caches against real tenders by design -- is not counted, and a table a pipeline starts writing tomorrow is. Counts rather than timestamps, because an update moves a timestamp and is not a leak. Verified by putting the leak back: "notices 15564 -> 15571", exit 1. The full suite passes with it: 3,139 tests, nothing moved. Also corrects two entries that said work was open five weeks after it shipped: the — hole in the long-dash guard (closed by 38470f9) and the .lead-head / .chg selector clashes (closed by ac75040). Both guards were re-verified today by reintroducing the defect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ew8LLXAEnxAnu9vDFWe3
5e9ce1btest_question_window.py commits its synthetic notices -- the code under test reads them on its own -- and its teardown deleted only the test company and user. 148 had accumulated in the local database since 2026-09-18. One of each batch is closed with its question window still open, and test_ics picks "the most recently closed tender", so it exported that notice's question alarm and failed on every suite run after the first of the day. The module records every notice it inserts and the teardown deletes them by id; every foreign key to notices cascades. Verified both ways: with the fix both files pass and nothing is left behind; with the cleanup removed test_ics fails again and seven rows leak per run. The 148 local leftovers were deleted by an exact predicate (synthetic buyer, fixture id shape, no source) with the count checked first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1d92bf8A popravek is its own notice and its pack is usually only the change, so the tender page, the reading room and the conditions finder -- all of which read the documents of the notice in the URL -- showed a corrected tender with one file where the buyer had published twelve. Measured on production: 174 of 622 open tenders have earlier publications, 170 of them holding files. The family is ingest.db.procedure_family, the change log's own query (_SIBLINGS is now _FAMILY plus LIMIT 2), buyer guard included. api/procedure.py labels each publication and keeps each file name once, from the newest publication that holds it: 690 of the 2,212 earlier files share a name with one in the latest pack, and listing both would offer the stale copy. The finder searches publication by publication, newest first, since a correction that restates the conditions is the buyer's current wording; a section from an earlier publication says so on the page. Lists group by publication only when they span two, so tenders without a correction render as before. Wording chosen by the user from options. Measured on production before shipping, read-only: 132 file lists gain 1,390 files; conditions 479 -> 546, exclusion grounds 396 -> 458, 0 lost and no existing section moved document. Finder median 16 -> 23 ms, p95 84 -> 93 ms; the reading room's list 4 ms. Every new test verified by reintroducing its defect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ac65f3btest_the_counts_decline set the question deadline to "now + 2 days 6 hours" taken in UTC and written without a zone, which the portal parser reads as Ljubljana time. After about 18:00 there that lands on the third calendar day, so the checklist correctly said "3 dni" and the test, expecting "2 dneva", failed. Found when a full suite run finished at 20:28 Ljubljana. The fixture is now noon two calendar days ahead in Ljubljana, which is two days at any hour. Verified at 20:39, the hour the old fixture failed. The two other fixtures built the same way only test open against shut with a margin of days, so they hold. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
c43b97eMeasured on production over all 622 open tenders, old finder against new:
308 -> 479 conditions sections, 0 lost; exclusion grounds 387 -> 396, 0
lost. And faster: the finder itself, both kinds, median 34 -> 9 ms, p95
93 -> 76 ms.
The misses were classified before any rule changed: 165 said the phrase
only where it was not accepted as a heading, 117 never said it, 31 had no
readable pack. What moved it:
- A bug in the shipped rule: search(topic[:40]) needs the phrase to END by
character 40, so "2.5 Dokazila za ugotavljanje obstoja pogoja za
sodelovanje" was cut in half.
- Ordered tiers, tried across the whole pack: the exact heading, then a
wider window, then the chapter's other names ("Ugotavljanje
sposobnosti", "Pogoji za priznanje sposobnosti", "Navodila o načinu
dokazovanja ponudnikove sposobnosti", the capacities alone). A single
wider window made 14 tenders worse: the combined "5.1 Razlogi za
izključitev in pogoji" beat its own sub-section 5.1.2.
- Long sentence-case headings that open with the phrase, as Word packs
that lost their numbering carry them, guarded by the next line not
continuing a sentence.
- The exclusion grounds cut out of a wider chapter, since the page shows
them in their own block; verified that the cut text is that block's.
- A chapter in capitals now ends at "MERILO" or "PONUDBA" instead of
running on to the 12,000-character cap.
Every new heading was read before shipping, and the false ones it found
are tests now: a vehicle's "tehnična sposobnost", a correction's "V točki
9.1.2", a bulleted declaration form, a TOC line, numbered prose. Each new
guard was verified by putting its defect back.
Still open, as a decision: 60 of the remaining misses are corrections whose
original notice's pack holds the section, and the tender page reads only
the correction's own files.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>ba70810Reported as "Nadzorna plošča" being glitched and misaligned, and it was both. The rail is a fixed-height flex column, so once its content outgrew the window every child was asked to shrink, and .rail-sub -- which carries overflow:hidden for the collapse animation, so its minimum height is zero -- was the one that gave. Measured at 1440 wide: 18px tall at 860, 14px at 840 (cut through the letters), gone at 820, with "Moje delo" jumping into its place. Nothing in the rail shrinks now; the rail already scrolls, which is what a short window gets instead. After: 18px at 900, 840, 820 and 768. It also sat at x=22, between the mark at 18 and the group headings at 24, in the same small capitals as those headings, so the product's own name read as an empty section. It is indented to the wordmark now (4 + 26 + 10 = 40px) and reads as one lockup. Collapsed, it still hides and nothing moves. Three guards in test_consistency, each verified by putting its defect back: no rail child may shrink, no later rule may let one shrink again, and the subtitle's indent is the sum that lands it under "TenderWatch". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
0d05782A 22 MB vehicle list (Elektro Ljubljana insurance tender) carries 177 MB of cached external-link cells, and openpyxl parses those in full even in read_only mode: the worker reached 3.6 GB and the kernel OOM-killed it 36 times on 2026-10-07/08. Every death was a fresh boot ingest that picked the same file first, and while it climbed the 8 GB box stopped answering, the Lixum site, n8n and Chatwoot with it. keep_links=False at both call sites: 33 MB peak, 0.9 s, every row still read. The API's viewer had the same call and no memory limit. And the reason it looped: the 2026-09-05 claim-before-read fix was made for the OCR pass only. The cheap pass now claims each document too, in read_error, which its queue already excludes and every surviving outcome overwrites. The next file that kills the process costs one document, not a day. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKnkTv8b2t9tYfiqc14WCB
f6c6422The minimum body was 200 characters, which rejected a real exclusion section of 169: the buyer's one sentence and its proof. It exists to reject a heading with nothing under it, so it is 120 now. Measured: seven more sections, all genuine; exclusion grounds reach 366 of 596 open tenders, conditions 306. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQ7nJWsh6PRUhhHScys4SQ
030fe07Same machinery as the conditions section, with its own start phrase and
sub-heading vocabulary (fakultativni, nacionalni, kazenske obsodbe,
DOKAZILO, ESPD Del III), measured on 150 production packs. It renders
folded inside "Razlogi za izključitev": the ten statutory grounds are
already answered there, so the buyer's text is worth opening for what it
adds, such as the optional 75(6) grounds and the proof it asks for.
Measured on production: 361 of 596 open tenders get the section, 183 with a
page. One read of the pack serves both kinds and each document is scored
once: median 33 ms, p95 77 ms on the tender route.
Also, for both kinds: a declaration form heading ("IZJAVA O NEOBSTOJU
RAZLOGOV ...") does not start a section, a "75. člen ZJN-3" line does not
end one, and the letter-spaced "S t r a n" footer is dropped as furniture.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQ7nJWsh6PRUhhHScys4SQeba5e78With model extraction on hold, every tender said only "Pogoji še niso izluščeni" while we held the text of the document that answers it. api/conditions.py finds the "Pogoji za sodelovanje" section in that text and the tender page prints it with its document, the page where one was observed, and a link into the reading room. No model call, no state, no verdict; the page says so. Measured on production: 305 of 596 open tenders get a section, 156 with a page; median 24 ms, p95 58 ms. Heading lines only, not prose mentions; tables of contents skipped in the four shapes measured; the ESPD excluded; page matched on the section body, not its heading; PDF line breaks re-flowed (whitespace only, pinned by a test). Also fixes the tender page calling the ten statutory ZJN-3 grounds "izluščenih iz dokumentacije ... z navedkom in stranjo" on 35 open tenders: the section now reads document-derived rows only, as /nadzor already does. And the empty state no longer says the pack was downloaded when it was not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQ7nJWsh6PRUhhHScys4SQ
29dd912Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQ7nJWsh6PRUhhHScys4SQ
8d3d7bdReported from a phone: the drawer opened by "Več" had a scroll bar. Measured at 390x844 with touch emulation: 1,001px of content, against about 664px that Safari leaves with its bars showing. Every cut is a duplicate, not a destination: - the "Moje delo" group, whose four links are the tab bar under it. Marked `in-tabs` when every key in a group is in TAB_KEYS, so it is derived. - the brand and the theme row, both already in the phone's topbar. Plus the account and sign-out on one row, the site link and build on one, and tighter air between links that keep their 44px targets. Measured after: 537px of content, fitting 390x664, 375x553 and 360x560. A new test checks that the drawer only ever hides links the tab bar shows; verified by widening the rule and watching it fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQ7nJWsh6PRUhhHScys4SQ
32889e2The whole UI read as cramped, and measured on the Pregled dashboard it was: 43% of the visible text was 12.5px grey meta on 16px lines, and another 36% was 14px semibold titles with negative tracking, half of them in capitals. Four faces were rendered over the real dashboard; the user chose Figtree with more room. - Inter -> Figtree everywhere the product renders (not the emails, which Outlook draws in system fonts). Inter's cv02-cv11 features go with it. - Scale 12.5 -> 13.5, 14 -> 15, 16 -> 17. Prose had to move too: 15 beside 16 is two sizes a reader cannot tell apart, and the scale guard said so. - px line heights follow their text (16 -> 20, 18 -> 20, 20 -> 22), with the fixed heights and two-line reserves paired to them, which is what left an empty half-line under one-line titles on /ujemanja until fixed. - List-row titles 500 instead of 600; h3/h4 lose the display tightening. - Dashboard rows get 3px more padding, as in the chosen preview. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6b0d974/app is now one dashboard with a Pregled · Nabiralnik · Teden switcher. The choice is saved on the server (user_ui_prefs) and opens next time; new users start on Pregled. /app?pogled= shows a view without saving it (a GET does not write); /app/predlog/* redirect; the previous overview stays at /app/prej. One rail for the whole product: Moje delo / Iskanje / Podjetje. Koledar leaves the rail (Teden absorbs it) and stays in the palette. Nabiralnik's Opravljeno is stored per firm (inbox_done). An item's key carries its newest change, so it returns only when something new happens. Done items are listed at ?opravljeni=1 and restored one at a time; every row has a done control on a phone. Guides: 3-4 steps per view on the existing tour engine, anchors checked over HTTP from the guide definitions. Offered by a small prompt once per view and never started automatically; dismissal is remembered; the compass replays the current view's guide. A one-time right-click hint on pointer:fine devices. Built, adversarially reviewed and fixed. Local branch only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY5
843b948api/facts.py computes, for a whole page of tenders in at most 6 statements (about 13 ms for 20), what Dokazila, the firm's references, the buyer's Q&A, the question window, Spremembe, the buyer's other open tenders, the below-threshold record and the documents say about each one. The three dashboard previews show up to two of these on each row (B shows all of them in its preview pane), and every row carries its own right-click "Predlogi": navigation only, same-origin paths only, labels escaped. Credentials carry reverse hints: the tenders whose deadline falls after they lapse. Seven foundation defects found by independent reviewers and fixed, each with a test that fails when the defect is put back: a question-window link to an anchor that only exists once the buyer has answered; midnight deadlines filed a day late (now whenever.due_day, one rule); the newest reference shown instead of the closest; the company-level declaration crowding row facts out of the menu; a reverse hint whose label promised a list and opened one tender; Q&A entries all called answers; "Odpri dokumentacijo" offered with no documents (new negative gate in the registry). Also fixes the live product: - bridge.evidence_for_notice compared papers against the UTC calendar date of the deadline, so a tender due at 00.30 Ljubljana raised no warning for a paper lapsing the day before, while the row chip said it lapsed. - The right-click "Razpisi tega naročnika" linked to ?narocnik=, which /razpisi does not read, and opened the national feed. Local branch only. The final combined test run was stopped by the machine running low on memory; the agents' own runs of the same suites passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY5
01d825cA (Pregled, urejen), B (Nabiralnik) and C (Teden), each built by one agent, re-measured and attacked by an independent reviewer, and fixed. Every one shows the first tender on the first screen at 1280x900 (202 / 182 / 285px) and at 390x844 (334 / 163 / 322px), against about 1,290px on today's /app, and none scrolls sideways at 390. The preview route passes the query string to each page, replacing a frame-reading shortcut two directions had used to reach ?vrsta= and ?teden= without editing the shared route. Also fixes views._expiry_words for the dual: it read "cez 2 dni" where Slovenian takes "cez 2 dneva". 3 and 4 stay "dni": after "cez" that is the accusative, not the nominative "dnevi". 970 passed, 2 skipped across the dash tests and the product-wide scans. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY5
d8bbfb1Literal routes /app/predlog/{a,b,c} beside the real /app, one module per
direction, and a rail/tabs override on app_shell so each direction can try
its own grouping without touching RAIL_SECTIONS. Local branch only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY59761a42open_opportunities read notice_type = 'cn-standard'. Reconciling a week of TED and the portal against the database found both sources complete -- 248 of 248 and 312 of 312 -- and six open, biddable tenders ingested but hidden: three cn-social (social and other specific services, ordinary tenders under the 750,000 EUR threshold) and three cn-desg design competitions. Decided by the user: every type a firm can respond to is shown. That is the eForms "competition" group -- cn-standard, cn-social, cn-desg, subco, pin-cfc-standard, pin-cfc-social, qu-sy. Results, veat and pin-only stay out. The portal keeps its own rule, its phase 3. The view is the one place that decides this; matching, the overview, the mail and the landing counts all read it. tests/test_open_types.py inserts one notice of every type and checks which reach the real view. With the old filter it names exactly the six hidden types. CLAUDE.md said only cn-standard was biddable, and is corrected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY5
0110088The last two open findings of the 2026-09-05 review. The other 44 had already been fixed; this closes it. L13. vault.delete unlinked a customer's file before the caller's commit, and accounts.delete_account did that for every file BEFORE deleting the company and the user -- the two statements most likely to fail. A failure there rolled back to an intact account whose certificates were already gone, under a docstring promising a failure leaves the account intact. vault.delete now only marks the file; vault.settle removes it after the commit, and only if the database says the row is gone, so a rollback keeps the file whatever order the caller used. The guard is derived from vault.delete through its callers, and found that evidence.remove and seed_demo._wipe commit themselves. L12. browse.py spelled the portal-only rule as LIKE 'SL%' twice, case-sensitive where provenance.NATIONAL_ONLY_SQL is not. Both use the definition now, and the guard scans every api module for both spellings, over string constants only. Also drops an unused `widest` in alerts.due_question_windows that failed lint. Every guard verified by reintroducing its defect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvkoTFHLbjeyH1FxDZqfY5
9476c4fDictation shipped on 2026-09-14 and streams a person's voice to the browser vendor's speech service. /zasebnost was written on 2026-09-05 and said nothing about it, for twelve days. The reason it went missing is the interesting half, and it is not carelessness. privacy.COLLECTED is DERIVED FROM THE SCHEMA -- a test fails if a table holding personal data stops being described -- and that is exactly what makes it trustworthy. Dictation stores NOTHING. No table names it, so no schema-derived guard could notice its absence, and the one feature in the product that hands somebody's voice to a third party was invisible to the mechanism built to catch exactly this. CLAUDE.md had even written the IOU down in the dictation entry: "That is not the whole obligation: /zasebnost is still a 404 and must cover this when it is written." privacy.SENT_ELSEWHERE is the section the schema cannot produce: what leaves the browser and is never stored here. It says the audio goes to the browser vendor (Google or Apple) and their policy governs it, that we receive neither recording nor audio, that only the recognised text lands in the field and nothing is kept until the field is saved, and that every field can be typed instead. The guard that replaces the blind one reads the SCRIPTS the product inlines rather than the tables it writes: a browser API that sends user input to a third party must be disclosed, derived from tests/sheets.py. A companion test fails if the scan ever matches nothing, because CLAUDE.md records two guards that were dead from the day they were written. Another fails if the page and the first-press toast ever disagree about where a voice goes -- two surfaces, one claim. Naming Google here does not break the rule against naming processors, and that is argued in a test rather than left as an apparent contradiction. A processor is somebody we contracted and can swap, and a notice naming one that changed is the stale claim this codebase keeps finding. The browser vendor is not our processor: the reader chose it by opening the page in that browser, we cannot change it, and refusing to name it would leave them unable to find out whose policy governs their voice. Four defects reintroduced and caught, including deleting the disclosure outright. The page still answers 404 until PRIVACY_CONTROLLER, PRIVACY_ADDRESS and PRIVACY_CONTACT are set. That is deliberate and unchanged: a notice that does not say who is responsible fails at its first job. This commit makes the page correct; publishing it is a decision about who the controller is. api/landing.py also carries the wedge heading correction from 0f14372, because one file holds both changes and git add -p is not available here. The heading "Objave, ki jih tuja orodja ne vidijo" becomes "Objave, ki jih orodja, zgrajena samo na TED, ne vidijo" -- the phrasing already used at four other sites in the same file, and the only one that survives every finding in that commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpAScUi8S7YRjNXoi4XmeV
9b2d030`datumDoVprasanja` is the last day a bidder may ask the buyer what a clause
means. It has been ingested on 10,622 portal notices since August and it
reached /razpis, /koledar and the bid checklist -- never the digest, which is
the one surface that arrives without the reader going to look. Measured on
production: of 329 open tenders stating one, 130 had already shut. You can
still bid; you can no longer ask.
Six things this had to get right.
The threshold has to be EARLIER than the bid reminders rather than merely
different. The median gap between the two dates is 7.0 days, so by the time
REMINDER_DAYS fires at 7 days before the BID deadline the question window has
typically already closed. QUESTION_DAYS = (3,) lands about ten days before the
bid deadline. A reminder that arrives after the thing it reminds you about is
worse than none, and a test fails if the two ever cross.
Saved tenders only, exactly like the deadline reminders. A firm can match sixty
open tenders and mailing them about all sixty is the alert-volume failure this
product exists to displace.
A window that has already shut is not mailed. The tender page says so because
the reader is looking at that tender; an unprompted mail saying "you can no
longer ask" is a reproach with nothing to do about it.
No state colour, the fifth time the four-state palette could have been borrowed
as decoration and the second time it was refused before shipping. The fact goes
in the card's neutral primary-tinted pill; the card's coloured edge goes on
being about the bid deadline, which genuinely is urgency.
`subject_for` takes the new source. CLAUDE.md records what happens when it does
not: a monitors-only digest fell through to the changes branch and went out
subject-lined "0 spremembe na razpisih, ki jih spremljate". The lead sentence
needed its own branch for the same reason -- without it a questions-only digest
claims something changed on a tender you follow, and nothing changed, a date is
arriving.
And the section backfill is ONE-SHOT, not merely idempotent. digest_sections
NULL means every section, so an untouched account picks up a new one for free;
an account that saved its preferences would keep it dark for ever. But
scripts/migrate.py runs on every deploy, so `WHERE NOT ('questions' = ANY(...))`
alone would re-enable the section for anybody who had since turned it off -- a
settings screen the next deploy overrules, which is worse than the gap it
closes. The marker column is what makes it happen exactly once per account.
`/razpis/{id}/rok.ics` already carried both events, so the two sections that are
ABOUT a date now offer it.
Ten guards verified by reintroducing their defect, one at a time -- the first
attempt stacked three and one masked another, so a guard looked like it passed
when it had never been exercised.
The email settings round-trip asserted "2 od 5" under a comment stating the
property "two of five, and the screen says so rather than implying all five".
Adding a sixth section broke a test whose property had not changed. It derives
from len(emailprefs.KEYS) now.
The same file carries a second, unrelated test fix, because it is the same file.
test_profile_produces_matches_with_reasons asserted "CPV 45000000" -- the firm's
own code. dc9bee4 changed the reason to name the NOTICE's code and bracket the
firm's when they differ, because naming only the company's code read as a claim
about the notice and was wrong on 1,483 of 2,062 stored reasons. The assertion
pinned the superseded wording and its branch is gated on "Zakaj:" being present,
so it only fired when a matched tender's CPV differed from the profile's, which
is why nobody saw it fail when the change shipped. It asserts the property now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpAScUi8S7YRjNXoi4XmeV0f14372A twelve-agent research pass over the field, adversarially critiqued, and the headline is about us rather than about them: the field barely moved since the August audit, and three sentences this file carried as settled fact are contradicted by primary sources. The wedge sentence said `SL1` notices are "invisible to TED-based competitors. That is the wedge." javnirazpisi.com states in its own FAQ that it monitors enarocanje.si for the ZJN-3 layer AND for naročila male vrednosti, plus "preko 3950 dodatnih povezav izvajalskih institucij" daily. Page-verified as a CLAIM, untested as a CAPABILITY -- their public search answers 410 Gone, so it cannot be checked from outside, and their sentence runs together two categories this project deliberately keeps apart. docs/17-wedge-test.md is the ten-minute experiment that settles it. The half that survives needs no competitor in it: the route code is a fact about the notice, api/provenance.py prints it per tender, and a reader can check it. TED names the Slovenian winner now, and has since roughly mid-2024. Measured against the live API and reproduced independently: 0 of 100 in 2023, 85 of 100 in 2026, a clean step at the eForms boundary. The sample is not random -- TED rejects sortField -- so it establishes the change and not a rate. The scope decision is untouched: we do not store supplier names because competitor win tracking is out of scope, full stop, and that reason never needed TED to be empty. The justification underneath it is deleted in CLAUDE.md and in ingest/awards.py. And the portal's terms are not the account-holder agreement. The Splošni pogoji are a public PDF whose Article 4 binds unregistered users by name to Articles 7, 8 and 11-14. CLAUDE.md records that question as closed on the strength of a 401 from a different document, which is exactly the entry that would stop a future session re-checking. None of the three is a competitor doing something new. All three are us describing the world from a document nobody re-measured, which is the failure mode this project names in a dozen places and had not yet applied to its own competitive brief. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpAScUi8S7YRjNXoi4XmeV
b428abfAsked for today: the same voice input `RC-LIXUM-ERP` ships, at the same level
of quality. That repo's `dashboard/src/lib/useDictation.ts` was read rather
than reinvented -- what is worth copying is not the forty lines that open a
recogniser, it is the half-dozen failures that hook has already been through
in front of real users. The shape differs because this product is
server-rendered HTML with no React: one script finds every field carrying
`data-dictate` and gives it a button.
It is the browser's own recogniser, so no key, no per-use bill and nothing
added to `requirements.txt`.
THE HEADER WAS THE WHOLE STORY
`api/main.py` sent `Permissions-Policy: microphone=()` under a comment saying
this product "asks for none of these" -- true when it was written. That header
is ENFORCED, and it is enforced on the one API a voice button needs. Measured
in Chrome against two otherwise identical pages from one local server:
microphone=() allowsFeature('microphone') -> false
microphone=(self) allowsFeature('microphone') -> true
and `'webkitSpeechRecognition' in window` stayed TRUE in both. So a mic placed
on feature detection alone would have appeared on every screen and worked on
none -- the dead control the ERP's own docstring exists to refuse, arriving
through a door that repo does not have. `DICTATE_JS` asks the policy as well
as the window, and a test ties the header to the feature so re-tightening it
fails loudly rather than silently killing voice. `self`, never `*`; camera,
geolocation and payment stay refused.
WHAT CAME ACROSS FROM THE ERP, AND WHY EACH ONE IS THERE
* a run cut short keeps what it heard. `spoken(final, heard)` has a name
because that repo paid for it in front of a customer: text that "just gets
reset, like it disappears, like it's on a timer". Both ways a run ends
early -- the listening cap, and pressing stop -- are ordinary.
* the utterance is joined across pauses. `results[0][0]` keeps the first
breath and drops the rest.
* `onerror` ends the run itself. `onend` does not reliably follow a
`network` failure, and the state left behind is a stop button that does
nothing.
* every failure kind gets its own sentence. "Nothing heard" and "the mic is
blocked" demand opposite reactions.
* teardown detaches before aborting, so walking away mid-sentence writes
nothing.
* hidden entirely where a microphone cannot open, never merely disabled.
* `sl-SI`, because the ERP shipped `en-US` twice and both times a sentence
was transcribed by an engine told it was hearing English -- confident
nonsense rather than a garbled box.
WHAT THIS PORT HAD TO ADD
Besides the permissions check: **setting `.value` from script fires nothing**.
`input` means a person typed, and three of these boxes -- the palette, the CPV
picker and `/vsebina` -- search from an `input` listener, so without a
dispatched event they would have filled with the spoken words and then sat
there. Found by reading the palette's own handler.
WHERE IT IS, AND WHERE IT DELIBERATELY IS NOT
Ten fields: both ask surfaces (`/pomocnik`, `/razpis/{id}/vprasaj`), every
search box (the palette, the feed, `/isci`, `/vsebina`, `/dnevnik`, the CPV
picker) and the two prose boxes (`/klic`, the profile's describe). Not the ops
page's search (`api/render.py` loads no scripts at all, so the attribute would
be inert markup) and not the walkthrough's textarea ("a demonstration must not
wire invented data to live controls"). Both exclusions are argued and both
reasons are themselves checked.
IT FILLS THE BOX AND WAITS. Never submits, never focuses the field afterwards
-- focus summons the keyboard over the control the reader is about to press,
which was this user's own report on 2026-09-06 -- and never arms itself. That
last one is measured under node against a recogniser that counts its own
construction, because the first version of that guard scanned the source for
`start(` and FAILED ON ITS OWN EXPLANATORY COMMENT: the sixth instance of a
guard matching the prose that explains the thing.
And the recogniser is not on-device. The browser streams the audio to its
vendor's speech service, so the first press in a browser says so before the
microphone opens. That is not the whole obligation: `/zasebnost` is still a
404 and has to cover this when it is written.
MEASURED, NOT ASSUMED
Placement at 390 and 1280 across six real screens: 44x44 everywhere, nothing
overlapping a field, nothing off the edge, and the two overlays checked with
the palette and the drawer forced open -- a closed dialog measures 43px
because of its resting `scale(.985)`, which is the transform trap CLAUDE.md
already records and which I checked rather than "fixed". The whole run was
also driven in Chrome against a stub recogniser: the button appears only
beside marked fields, interim text appends to what was typed, the final text
commits joined across a pause, `input` fires, focus is not stolen, and a run
with no final result keeps its words.
AND A RULE THAT HIDES "EVERY CHILD EXCEPT" CANNOT KNOW ABOUT A NEW CHILD
`.filters.foldable.folded` collapses the feed's filter bar on a phone by
hiding every direct child except the search input and the toggle. The
microphone is a third direct child, so it was `display:none` whenever that bar
was folded -- which is its DEFAULT state on the unfiltered feed. The busiest
screen in the product would have shown a search box with no way to speak into
it, on the one device dictation exists for, and nothing would have failed: the
markup is valid, the button is in the DOM, and every other screen was fine.
Found by measuring the rendered bar at 390px and noticing no button where five
other forms had one.
`tests/test_mobile.py` caught an ungated `:hover` on the mic before it shipped
-- a hover that sticks after a tap, on the one control in the product most
likely to be tapped rather than pointed at.
AND IT COLLIDED WITH A SECURITY TEST, WHICH IS NOW STRONGER
`test_throttle.TestALockedAccountIsNotAnOracle` scans a locked-account login
reply for "zaklen", "blokir" and "preveč poskusov", because leaking the lock
state tells an attacker the address is real. The auth page inlines this script,
whose "Mikrofon je blokiran" carries `blokir` on every login page ever served
-- identical in all three replies, so it leaks nothing, while a substring scan
over the whole body cannot tell that apart from a real leak. Sixth instance of
a guard matching prose rather than the thing it is about.
Narrowing it to visible text ALONE would have been weakening a security test,
so the property underneath it was added in the same change: the reply for a
locked account and the reply for an address with no account must be
INDISTINGUISHABLE once the echoed address is blinded. They are, byte for byte.
Verified by planting an HTML comment in one branch -- invisible to a reader,
perfectly readable to an attacker, and named by no word list. The old check
would have passed it.
Ten guards, each verified by reintroducing its defect and watching only the
test named for it fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4La77d9b2Two rules from the freshness work, both cheap to lose and both already paid for once. A stored timestamp is UTC and anything a reader sees goes through `whenever.SI` first -- `views.when_sl` was the one helper in the product that printed a clock face and the one that had never been given the timezone module written for exactly this. And `notices.last_updated_at` is not "when the buyer changed it": the upsert has no WHERE, so it means when we last wrote our copy, and the ingest window is what makes it freeze. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
7cbdc82Asked for today: "when we click into a tender, show which portal it is from, and also show when it was last updated -- I don't know if the portals are updated or not." The first half already existed and is worth saying so: `api/provenance.py` has printed the source and both identifiers under the meta row since August -- "Objavljeno na TED in na Portalu javnih narocil. Imamo oba zapisa. JN006678 · 593979-2026" -- plus a link to the notice's own page on each register. The second half did not exist at all. Measured across three real tenders before building it: `posodob`, `osvez` and `preverjen` appeared nowhere on the page. TWO FACTS, AND EITHER ONE ALONE MISLEADS `notices.last_updated_at` is `now()` on every upsert -- there is no WHERE on the DO UPDATE -- so it means *when we last wrote our copy*, not *when the buyer changed anything*. The catch is the ingest window: the nightly run asks each source for the last INGEST_WINDOW_DAYS of publications (7 on production), so once a tender is older than that nothing re-reads it and the column freezes. Measured on production today over 551 open tenders: 423 were re-written this morning and 128 were not, the oldest carrying a date 19 days back. Print that alone and a reader on one of those 128 concludes the intake is dead. Print only the source check and a tender the run never asked about reads as verified an hour ago. So the line carries both, and the second clause says the ZAJEM ran -- a fact about the run log -- rather than claiming this notice was verified. Nas zapis osvezen 26. avg. 2026 ob 16:23 · zajem nazadnje tekel danes ob 06:11. THE OLDEST SOURCE, NEVER THE NEWEST. A tender on both sources is only as freshly checked as the source quiet longest. CLAUDE.md records this twice already -- `ingest.status` reading the newest run across all sources so a healthy TED run hid a portal ingest dead for a week, and `health.pill` sorting two `ok` rows by recency so a source dead for twelve days lost to one that ran an hour ago. `provenance.PORTAL_HELD` takes the portal alone for the same reason: its route code says the buyer published to TED and we never matched that record, so the TED run demonstrably did not re-read it. The run keys live on `provenance.Source` rather than in a lookup beside it -- where a tender came from and what re-reads it are one question -- and a test checks them against the schema enum, because a typo there produces no run row, no exception, and a silently shorter sentence. AND THE CLOCK WAS UTC EVERYWHERE `when_sl` IS USED Found building this, and it would have shipped the same error on the field this commit exists to report. `views.when_sl` read the hour and the day straight off a UTC-aware value, so the intake finishing at 04:14 UTC rendered "danes ob 04:14" to a reader whose clock said 06:14. Two hours out all summer, one all winter. The day is the worse half: between midnight and 02:00 local the UTC date is still yesterday's, so for two hours a night it answered "vceraj" about something that happened today. `api/whenever.py` exists for exactly this and says so in its own docstring -- it was applied to the deadline arithmetic and not to the one helper that prints a clock face. `/spremembe` and the mail preview are fixed with it. MEASURED ON A PHONE, because the header card is what the 2026-09-03 reorder fought for. At 390x844 the line costs 43px (it wraps to two): the card goes 498 -> 541px and the four-state chips move from y=406 to y=449, still well above the 791px fold. Nothing the phone fix protects moved below it. Lateness uses `status.stale_after()`, not a typed hour count, so the demo box -- which ingests weekly -- does not mark every tender on it late. Six guards, each verified by reintroducing its defect and watching only the test named for it fail: newest-instead-of-oldest, the UTC clock, a literal staleness threshold, the line dropped from the header card, a run key that matches no source, and the route forgetting to hand the run log over. That last one is the silent axis -- `checked` defaults to None so the page still renders and only the second clause disappears. Also lands the single ranked plan across the four daily-flow audits (`docs/15-daily-flow-2026-09-14/00-the-plan.md`), promised when they finished. Eleven items ordered by readers-hit times distance-from-the-decision, with what has already shipped marked so it is not re-discovered, and the one finding deliberately left open named as such: the tender page still claims ten conditions were extracted from documentation that was never read, and the honest repair is a four-state-model decision rather than an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
21fdfdfFound by the accessibility audit, while trying to save a tender with the keyboard. `CHROME_JS` raises a loading skeleton when a navigation starts, with `content.replaceChildren(...)` on `.content .container`. Every POST form in the signed-in product lives inside that container -- save a tender, mark a bid, answer the ZJN-3 declaration, save the profile. Raising the skeleton from inside the `submit` event therefore disconnects the form that is submitting, and a disconnected form does not submit: Chrome abandons the navigation and says so in the console. MEASURED, on the live demo, in a real browser, on a real form on `/ujemanja`: `form.isConnected` is **false** inside the submit handler. After deferring by one task, measured the same way on the same page: **true**. Deferring is the whole fix. The submit event finishes, the browser starts the navigation with the form still in the document, and the skeleton paints immediately after -- unchanged for the reader. ⚠️ WHAT I COULD NOT ESTABLISH MYSELF is the end-to-end consequence. Three attempts at a request-level proof all failed in the same direction: the control case never produced a POST either, because `--virtual-time-budget` exits the browser before a request leaves and `--dump-dom` captures the first load rather than the navigation. An instrument that cannot see the control cannot report on the subject, so it is not cited. The mechanism above is measured; that every write in the product was cancelled is the auditing agent's observation plus documented browser behaviour, and it is written down as that rather than as mine. The fix is right either way: removing the submitting form from the document is indefensible whatever a given browser tolerates. ⚠️ NOTHING IN THE SUITE COULD HAVE CAUGHT IT. No test drives a browser, and a `TestClient` POST never runs this script -- which is why 85 forms pass their tests. The guard is therefore a scan for the SHAPE, a skeleton raised synchronously from a submit, plus a second test pinning its own premise so it cannot quietly stop applying. Also included: the accessibility report, which is unusually clean where it counts -- 0 unnamed controls of 383, 0 of 265 tab stops without a visible focus ring, 1 contrast failure in 1,314 text nodes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
6c2bf89Two things, both asked for today.
## The log, at the ERP's level
`/dnevnik` merges eight sources and had no search. It has one now, and the
design decision is where the filtering happens.
⚠️ PUSHED INTO SQL, NOT OVER THE WINDOW. The obvious build filters the
assembled stream in Python. The page shows the newest 80 rows, so that
answers "is it in the last 80" while LOOKING like it answers "did this ever
happen" -- and a reader cannot tell those apart, which makes it worse than no
search. Every source filters itself instead, so an answer reaches as far back
as its table does. `RC-LIXUM-ERP` hit the same wall and solved it by raising
its feed from 60 rows to 150; this does not need a window at all.
⚠️ AND IT SEARCHES WHAT THE READER CAN SEE, which is the ERP's own rule --
"searching fields they cannot see is how a list starts returning rows that
look like they do not match". The corollary bit immediately: half of every
row is a heading composed in Python and present in no column. Measured before
it was handled, `zajem` -- the word printed on 41 rows then on screen --
returned **0 results**. Headings and enum labels are both resolved now, so
typing a word off the screen finds it.
One matching semantics: `FOLD` is imported from `api/search.py` rather than
re-typed, diacritics fold and the inflected ending is bridged, exactly as
`/razpisi`, the CPV picker and `/isci` do.
The scope tabs carry the query, an empty search and an empty log say
different things, and a count of zero never reaches the label because the
empty state already says it in a sentence.
## POST /razpis/{id}/vprasaj answered 500 to every submission
Since the feature's own commit on 2026-09-02, 118 commits ago. It called
`demo.blocked`; the function is `demo.blocked_reason`.
Two things hid it and the second is the lesson: nothing links to that screen,
and `tests/test_ask.py` drives the route three times -- all three GETs. The
page renders fine; only asking fails. A screen sweep that never presses the
button is a sweep of half the app.
`TestEveryPostIsRequestedAtLeastOnce` presses every parameterised POST the
router declares, demanding only that the answer is not a 5xx. ⚠️ Its own
first draft typed five route paths from memory and named five that do not
exist; the test caught that on its first run, which is the only reason it is
not a list of plausible fiction.
Also included: the three audit reports the flow team produced today, measured
against real renders. Their findings are not acted on here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L54aed82Two leftovers from `docs/14-review-2026-09-05.md`. L1. `evidence.expiry_from_papers` joins `company_credentials` to `company_documents` on `credential_id` and constrained only the side its WHERE happened to name. A `company_documents` row whose `credential_id` pointed at another firm's credential would have its extracted text read and rendered on `/dokazila` -- the text of somebody else's certificate. Latent, correctly: `vault.store` checks the owner, so nothing can create that row through the product. It was one write-path bug away from a cross-tenant disclosure, and it is the only such JOIN in `api/`. Both sides are scoped now. The test plants the row the writer refuses to create and asserts the read still refuses to return it -- verified by removing the scope and watching another firm's text come back. G5. `test_every_runner_locks_before_it_opens_a_run` globbed `ingest/run*.py` while five of the ten runners that open a run row live in `extract/`. The docstring said "every runner" and it checked half. Nothing was broken behind it -- all ten take the lock first -- which is the good outcome and not a reason to leave a guard looking at half the code. Same defect `tests/test_pacing.py` already had, fixed the same way. ⚠️ And my own widening had the prose hole this codebase keeps hitting: it accepted the string `PORTAL_LOCK` as evidence of locking, and `extract/run_chunks.py` names it in a COMMENT explaining which lock it does NOT take -- so renaming its real lock away left the guard green. Comments are stripped and the CALL is the only evidence. Found by making the probe weaker until it stopped failing. A count assertion guards the sweep itself: ten runners, not five. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
09f4f16Read out of Traefik's own acme.json rather than inferred:
lixum.agency notBefore=Sep 12 18:50:55 2026 notAfter=Dec 11 2026
DNS-01 issues through an orange-clouded record, which the entry above
predicted and which nothing had yet confirmed. Every other host on the box is
current, the nearest expiry being chat.lixum.agency on 15 October. No dated
certificate risk remains.
That closes the last item this brief carried a date against.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L289d3d1Backticks inside a double-quoted bash string are command substitution, so `ingestion_runs` was executed and replaced with nothing. Same family as the heredoc escaping already recorded three times: generate prose with Write or Edit, not through a shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
924fce7Six deploys in one day turned a documented cost into a measurement: ted and enarocanje crawled 4x, document_text 12x, one OCR pass killed mid-flight, and the operator was mailed twice about churn the deploys were causing. Also recorded: the local-work argument I made and then disproved within the hour, the permanent failure floor that hides a new failure, and a guard that measured indentation as a proxy for scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
877cf12It writes the same `document_text` run row as the cheap pass and was the only stage a deploy still started. Its own comment is right that killing it costs no DATA -- it commits per document and picks up where it stopped -- but it costs a row, and the `interrupted` record on production came from exactly here: a deploy recreating the worker mid-pass, at about 82 seconds a document. A deploy that has skipped every other stage should not then start an hour-long job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
a7335f8Reverses my own reasoning from an hour ago, on evidence the deploy produced. The first version gated only the crawls, arguing that re-reading files we already hold "costs nothing anybody else can feel". Shipping it disproved that. `document_text` is a local stage and it ran **10 times in 24 hours** across six deploys, above `RUNS_PER_DAY_DEGRADED` -- so the box reported DEGRADED, correctly, about churn the deploys themselves caused. One of those runs is recorded `interrupted`: a deploy recreated the worker mid-OCR, at about 82 seconds a document. So the local half costs a false alarm, wasted CPU and a killed long job -- just not to a third party. And it has nothing to do: a boot that skipped the fetch has fetched no new packs, so the read stage has nothing new to read. Every scheduled stage is gated now. A deploy-caused cycle writes no `ingestion_runs` row at all, which is the honest description of a deploy. A fresh box still fills up, because nothing has run and nothing is skipped. ⚠️ AND AN EXISTING GUARD CAUGHT THE CHANGE, ON A PROXY RATHER THAN THE PROPERTY. `test_it_is_not_gated_on_the_pack_crawl_switch` measured the INDENTATION of the `run_company_text` call and allowed one level past `if docs_enabled:`. Adding a legitimate second condition around the call broke it while containment was unchanged -- the stage was still outside that switch and the guard could not tell. It walks the AST now and asks what its docstring says. ⚠️ The first rewrite of that guard was WEAKER than the indentation check it replaced: it matched only a bare `if docs_enabled:` and passed when the call was moved under `if docs_enabled and not _skip_on_boot(...)`. Found by reintroducing the defect, which is the only reason it is not still that way. It now rejects any condition that mentions the switch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
f68f3b9Found by reading the worker log after a deploy rather than by looking for it.
`run_text`'s cheap pass selected on `extracted_text IS NULL`, and a document
that raised `Unreadable` keeps that NULL -- so the same files were re-read on
every run. Measured on production: 42 documents carry a `read_error`, and the
nightly pass reports
done: 0 read, 30 skipped (nothing worth storing), 41 failed
every night, the same 41 files. The run is right not to fail on them: the
brief is explicit that a drawing which will never parse must not leave the
box permanently DEGRADED. That is exactly what makes it dangerous. **A
permanent floor of 41 is what hides the forty-second** -- a new failure
changes a number nobody can read as new.
⚠️ THE OCR BRANCH TEN LINES ABOVE ALREADY KNEW. It excludes
`ocr_attempted_at IS NOT NULL` and its comment gives this same reasoning --
"a growing permanent set eventually crowds out every document that could
still be read, while the run reports success throughout". The cheap branch
beside it never got the guard.
Two halves, because the first alone would be worse than the defect. The pass
skips a document whose read already failed, AND a re-fetch clears the verdict
-- without that, a pack re-fetched with the file fixed would never be read
again and a transient failure would become permanent. `--redo` still retries
everything, which is what it is for and precisely what you want after
changing the reader.
⚠️ `ocr_attempted_at` is deliberately NOT cleared by a re-fetch. That claim
exists to survive a SIGKILL -- one file OOM-killed the worker thirteen times
-- and re-arming it on every pack refresh would let the same file do it again
at 82 seconds a go. A failed cheap read costs milliseconds. The asymmetry is
about cost and is tested.
Measured locally: 7 candidates became 1, and the six that dropped out are
exactly the six carrying an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4Ld64ca0d`INGEST_RUN_AT_START` defaults true, so a restart -- which is how this app
ships -- runs the whole cycle immediately. A previous fix stopped that
mailing the customers a second digest. The CRAWLING was left alone, and
`CLAUDE.md` has carried it as an open item since.
Four deploys today made it concrete. Measured on production:
ted 4 runs in 24h
enarocanje 4
enarocanje_qa 3
where each should run once. Nothing alarmed -- `RUNS_PER_DAY_DEGRADED` is 6
-- and no data was ever at risk, because every ingest is idempotent. The
entire cost falls on a public register this project has promised to be
conservative with, which the brief calls the half that actually matters.
A deploy-caused cycle now skips any source that already completed inside its
own cadence. The window is `ingest.status.stale_after()`, deliberately: if
the watcher would still call a source fresh, a deploy has no reason to crawl
it again, and the two cannot drift apart about what "recently" means.
⚠️ What must not break is the reason the boot run exists, and all three cases
are tested. A FRESH box has no runs, so nothing is skipped and it fills up. A
box that missed a night has a last-good older than the window, so it catches
up. Only a restart shortly after a healthy run goes quiet.
⚠️ Only work that leaves the box is gated. `document_text`,
`knowledge_chunks` and `company_text` read files and rows we already hold, so
re-running them costs nobody anything and a deploy should still pick up a
document the last run left unread. `CRAWLS` is checked against the run-log
enum, with two exemptions: `ON_DEMAND` derived, and `buyer_site` named,
because it is an enum value no runner has ever written.
⚠️ A first version of the failure-path test pointed at a dead address and
hung the suite for minutes -- psycopg waits out its whole connect timeout.
The connection is made to raise instead. Same family as the 200-second
`localhost` trap in the brief.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L5716304Reported by the user. It never touched the theme -- `data-theme` stayed
`light` throughout -- but `.tour-lit` carried
box-shadow:0 0 0 9999px hsl(var(--foreground)/.10)
and a 9999px spread IS a full-page scrim. Measured in light theme at
1024x740, spotlight off then on:
page ground (247,247,248) -> (223,224,225)
the rail (255,255,255) -> (231,232,232)
A 24-level near-black veil over everything, which is indistinguishable from a
theme change to the person looking at it.
⚠️ The rule's own comment read "an outline, never a full-page scrim", two
lines above the declaration that was one. Another instance of a comment
asserting a property the code does not have.
⚠️ And the deeper fault is the token, not the size. The tint was
`--foreground` -- the INK -- so the same rule painted a WHITE veil in dark
mode and lightened the page. A highlight built from the ink inverts with the
theme and means the opposite thing in each.
What replaces it is emphasis on the target rather than suppression of
everything else: a bounded 6px halo in `--primary`. Measured after, the page
is pixel-identical to its unlit state, and the halo reads the same in both
themes (rgba(46,78,112,.14) light, rgba(150,195,227,.14) dark).
The `prefers-reduced-motion` rule that switched the shadow off went with it:
it existed to suppress the scrim, and a static halo is not motion.
Guards: no sheet anywhere may carry a shadow spread of 500px or more, and the
spotlight may not be tinted with `--foreground`. Both verified by putting the
original declaration back. The scan strips CSS comments first, because the
entry explaining the fix quotes the removed rule verbatim and a raw scan
reports the fix as the defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L201796eThe fix is worth one entry; the way I got there is worth two. A probe that sampled a transitioned property under virtual time reported the rail collapse as broken when it works, and I wrote that into the source as measured fact before catching it. The rule is narrower than remembering the trap: check `transitionProperty` before believing a frame-by-frame reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
bf34840Reported from a real device: scrolled to the bottom, the rail's white panel
ended partway up the screen and the page ground showed under it.
The rule is `height:100vh; height:100dvh`, which is correct, and Chrome
honours it exactly -- measured at 1024x740 across four screens, the band under
the rail is 0px. So it could not be reproduced by asking a browser for a
viewport, only by reproducing the CONDITION: pin the rail's box 155px short,
scroll to the bottom, and read the pixel where the surface used to stop.
without the backdrop x=20 y=615 (20, 22, 25) the page ground
with the backdrop x=20 y=615 (33, 36, 41) the rail surface
So the fix is not a better unit. A `position:fixed` box is laid out against
the visual viewport and re-anchored by the browser when that changes, so it
paints the column whatever `dvh` reports on any device. It is desktop-only:
below 900px the rail is an off-canvas drawer, and a permanent white stripe
down the left edge of a phone would be a worse bug than this one. Measured at
390px, the pseudo-element's `content` computes to `none`.
`tests/test_mobile.py` caught it immediately as a new fixed bottom-anchored
thing, which is that guard doing its job. It is admitted on a third argument,
narrower than the two already there -- not a control, `pointer-events:none`,
`z-index:-1`, confined to the rail's own column, never over the reading
column -- and a companion test holds it to exactly that, verified by removing
`pointer-events:none` and watching it fail.
⚠️ AN HOUR WENT INTO A MEASUREMENT THAT WAS WRONG. Sampling the rail's width
through a collapse said it never moved off 248px, and I wrote a comment
stating the variable had broken the control outright. It had not:
`CLAUDE.md` already records that under `--virtual-time-budget` a transitioned
property reads back at its START value however long you wait, and `width` is
transitioned. With `*{transition:none}` injected first, the truth is expanded
rail 248 / backdrop 248 and collapsed 62 / 62. The comment now says what was
actually established.
The rail keeps its literal width and only the backdrop reads `--rail-w`,
because whether a `var()`-driven width still animates is the one thing this
environment cannot settle, and the collapse motion was built deliberately. A
guard fails if the two numbers ever drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L0981328Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
59c9ec2The fix shipped and the demo still said "Zajem ne teče". v319 was serving, so the code had landed; `INGEST_EVERY_DAYS=7` was set on the demo's `worker` alone, and `/stanje` and the topbar pill are rendered by `api`. That process read no cadence, defaulted to daily, and called a four-day-old run BROKEN on a box that ingests weekly on purpose. So the fix for "a setting nothing reads looks effective" was itself in a process that could not read the setting. `CLAUDE.md` records the shape twice already -- `INGEST_DAYS` versus `INGEST_WINDOW_DAYS`, where the behaviour happened to be right so the typo was invisible, and a storage volume mounted on the service that never receives the upload. In all three the configuration reads as correct and does nothing, and in all three there is no runtime moment at which anybody finds out. The guard is derived at both ends: the variable names come from what `ingest/status.py` actually reads, the services from the compose file. It fails when the two disagree about any of them, so a third such variable is covered without anyone remembering. Verified by deleting the line and watching it name `INGEST_EVERY_DAYS: worker='7' api=None`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
b24a6e9Decided by the user, 2026-09-10. `/isci` and `/vsebina` both carried an unconditional `autofocus`, so arriving from a shared or bookmarked `?q=...` -- which is arriving to READ -- raised the software keyboard over the results. On a 390px phone that covers roughly the bottom third. Not "focus only when the query is empty", which was the first narrowing: removed outright, so the product answers this the same way everywhere. The assistant drawer focuses its panel rather than its field for the same reason, and it costs one tap when you did arrive to search. The search palette still focuses its input and is not an exception: it uses a script rather than this attribute, it is summoned by a key press, its whole content is one query field, and it sizes itself to the keyboard. The rule is "either do not raise the keyboard, or make room for it". The guard is derived over the package, replacing a per-module one, and two things went wrong writing it -- both instructive. It first failed on PROSE: a docstring in `views_search` and a JavaScript comment in `views_copilot`, both of which name `autofocus` in order to say it is gone. It looks for the attribute in markup now, not for the word. ⚠️ And its `<\s*(input|textarea|select)\b` arrived through a heredoc with the `\b` turned into a literal backspace, so the pattern matched NOTHING and the guard passed by being unable to fire. Fourth instance in this repository. `TestNoSourceFileCarriesAnInvisibleControlCharacter` catches it and names the line -- confirmed by putting it back -- and the only reason it did not is that I had run the file with `-k`. Run the whole file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
3556341The full suite caught my own change. `spent_today` and `asked_today` built
`WHERE {column} = %s` from a checked pair of identifiers -- safe, and
invisible to `test_every_company_scoped_query_actually_compares_the_company`,
the guard written after `activity._recorded` showed every tenant's log to a
profile-less account. It reads the SQL looking for the comparison, and an
f-string hides it.
Four named statements instead, two per question. A tenancy guard that can see
the scope is worth more than the duplication, and the interpolation into SQL
is gone entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L540506bThree problems the user reported about the search, all measured on real
renders.
**It could not find functions or shortcuts.** `search.ACTIONS` was a
hand-typed tuple covering 18 addresses against 44 parameterless GET routes,
so `/predstavitev`, `/klic`, `/zasebnost`, `/isci` and twenty-two more were
unreachable by name; of 50 words a person would plausibly type, 27 found
nothing. The existing suite could not see it, instructively: a test already
derived `RAIL_SECTIONS` and passed on all 13 rail entries, so the derived
half was green while nothing ever asked the ROUTER. The screen set is a
two-bucket partition checked against the router now, each exclusion carrying
a written reason, titles derived from the rail so one screen cannot have two
names, and extra words derived from `context.SECTIONS`. Keyboard and gesture
shortcuts are a new group at `/isci#bliznjice` -- links, never key presses,
because the palette navigates and never executes.
**The cancel button's font** was the visible corner of the same defect that
cost the version number its size: `font:600 14px/1 inherit` is invalid,
because `inherit` is not a family the shorthand accepts, so the browser drops
the whole declaration. "Prekliči" computed as 11px monospace inside a 44px
pill. Five palette declarations had it, and every result row rendered its
title and its detail line at the same 16px -- 24 hits needed 1908px of list,
now 1518px at a uniform 57px per row. `theme.BASE` also gained
`button,input,select,textarea,optgroup{font-family:inherit}`, which is the
class my own probe found separately.
**The screen breaking on scroll** was not a scroll-lock failure -- that was
ruled out by measurement at nine viewport sizes. `.pal` had no dynamic
viewport unit and the script wrote an inline height on every `visualViewport`
resize, and a retracting phone address bar IS one: opening the search and
scrolling produced six inline rewrites of a full-screen dialog's height, each
reflowing the results under the thumb, and a dropped event left the dialog
stale with a band of page under it. Now `100vh` then `100dvh`, and the script
sizes only for a software keyboard behind a 120px floor. Toolbar retraction:
six writes to zero, gap still zero.
Guards for all of it, each proven by putting the defect back, including a
scan over all 33 sheets for the invalid shorthand. Three of the agent's own
guards were wrong first and were only caught by running them.
Left open and worth knowing: the shared scroll lock is
`documentElement.style.overflow`, and iOS Safari is known not to honour it
for a drag starting on a modal's non-scrolling part. Not reproducible in
Chrome, not guessed at. If that turns out to be the real complaint it is one
change across the palette, the rail drawer and the assistant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4La27dc0cEverything in this batch was measured against the running system before it was written down, and every guard was verified by putting the defect back. The entries worth reading twice are the ones where the defect was invisible by construction: eight tenders shown twice and eight never shown at all because the ORDER BY had no unique tie-break; 12,127 buyer records the tender page claimed and `/narocnik` could not show; two daily spend caps that did not apply to an account without a profile; and a `font:` shorthand ending in `inherit`, which is invalid, so the whole declaration is dropped and a 26px heading renders at 16. Also recorded: three ways this session's own instruments lied. Editing source while the suite runs makes `inspect.getsource` return a different function. The bash heredoc in this harness collapses one level of escaping, for the fourth time. And a probe that reported three monospace stacks as a defect was wrong -- measured, all three resolve identically here, which is why the entry says so rather than quietly dropping it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
0dead19Reported by the user: opening the AI should not jump into the prompt box and raise the keyboard. Measured in headless Chrome at 390 with touch emulation -- `(pointer:coarse)` confirmed true, and false without it, so the distinction was real. The drawer autofocused its field on both pointer types. `.cop-panel` is fixed to the bottom, so the field sits at y=771..844 while an iOS keyboard covers everything below roughly y=508: the autofocus raised a keyboard OVER the very field it had just focused, 263px under the fold, and hid none of the four example questions the reader opened the drawer to read. Opening now focuses the panel, not the field. Closing puts focus back on the launcher -- moving focus in obliges moving it back, or a keyboard reader is stranded on a `visibility:hidden` element -- and `aria-expanded` tracks the state. Tapping an example still focuses the field, because opening is "show me the assistant" and tapping an example is "ask this". Deliberately NOT conditional on `pointer:coarse`: there is no keyboard shortcut into this drawer, so the "a keyboard user asked for the field" case does not exist, and `pointer:coarse` answers whether the primary pointer is coarse rather than whether a software keyboard will appear -- a Windows touch laptop and an iPad with a keyboard case both report fine and both raise one. The guard is EXECUTED, not read: it runs the drawer's own inlined script under node against a stub DOM built by parsing the real markup, and asks what has focus. The product-wide rule it enforces is "either do not raise the keyboard, or size for it", which the search palette passes honestly because it sizes to `visualViewport`. ⚠️ One of its guards was itself blind, found only by reintroducing the defect: it scanned for `visualViewport` and matched the comment that had just been written to explain the fix. It strips JS comments now. Fifth recorded instance of a guard matching the prose about the thing. Still open, not this agent's files: `/isci` and `/vsebina` carry a literal `autofocus` that fires even when you arrive with `?q=` to read results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
5fd51daFive findings from `docs/14-review-2026-09-05.md`, each reproduced first and
each guard verified by reintroduction.
M7 was far worse than the review recorded. `bridge.upcoming_from_history` was
the one evidenčna query with no band bound, so the tender page and
`/narocnik` described the same buyer differently. Measured read-only on
production: of 23,492 recurring (buyer, description) pairs the tender page
could render, **12,127 have zero rows in the 10k-80k band** -- the click
through shows nothing at all -- and 1,183 more disagree on the median. 57% of
the claims were invisible or inconsistent one click away. The worst example
is this brief's own warning sentence made real: a primary school, "B-knjige",
four years, 389 rows, median 40 EUR, rendered under "Kupujejo redno".
The other three `BETWEEN 10000 AND 80000` literals in that module now use the
band constants, so a guard has one spelling to match rather than two -- and
the guard walks the AST for every string naming the table, because both
existing band tests named `buyer_history` by hand, which is exactly why its
sibling was missed.
M12. A photographed certificate is accepted, stored, and was a candidate in
none of the passes, so `read_at` stayed NULL for ever while `--stats` counted
it in two places at once and reported `scans_waiting: 0`. The module's
docstring claimed "the gap reports itself". OCR's default does NOT move --
its stated reason is about the 25 MB upload, not the format -- but the gap is
now visible in `--stats`, warned in the nightly run, and written onto the row
so `/dokazila` tells the one person who can act by uploading a PDF. The
warning deliberately does not offer `--ocr`, which reads no image.
L2, L3, L4: a junk id 500'd on `/iskalniki/{id}` and on the evidence upload
while every sibling answered 404, and `/razpis/{id}/rok.ics` set a 48-hour
alarm on a deadline nine days past. The L3 fix is in `vault.store`, because
"it is not even an id" is one of that function's own answers and the handled
message was two lines below; the L4 fix is in the formatter, because three
call sites build events and a caller gets an alarm by writing nothing. L2's
guard drives every parameterised GET route with four junk values rather than
naming the one that broke.
⚠️ `tests/test_ics.py`'s own fixture had silently become past -- `at=2026-09-04`
-- so `test_a_future_deadline_gets_one` had been asserting a property of a
past deadline since the 4th and would have passed for ever without testing
its own name. That is the pinned-bug family arriving through a fixture.
The `/dokazila` half of the routes work landed in 7614d62 alongside the spend
caps, because both touched `api/routes_app.py`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4Lc77c14cThree operator-alarm findings from `docs/14-review-2026-09-05.md`, each
reproduced first and each guard verified by putting the defect back.
M2. The zero-notices alarm tested `status == 'ok'`, and `partial` is exactly
what a portal run records when its notice fetches fail -- the failure that
alarm was written for. Injected one `enarocanje` run with `notices_seen=0`
and varied only the status: `ok` raised BROKEN, `partial` raised nothing.
`COMPLETED = ("ok", "partial")` is the one definition of "a run that finished
and reported a count" now, and both `last_good` and the alarm read it.
`running`/`interrupted` stay out: their count is the zero `start_run` opened
with.
And `/stanje` -- the page the topbar pill links to -- painted the same row
green, because `health.sources()` kept a second severity ladder while
`pill()` demotes `partial`. It grades through `_severity` now, so a status
added to one reaches both. `RUN_WORDS` also gained `partial` and
`interrupted`, which had been rendering as "Partial" and "Interrupted" inside
Slovenian copy.
M16. A failed operator alert advanced `ops_state` anyway, so the message was
lost for good and the only trace was a log line. Measured with the relay
down: two heartbeats, no send attempted on the second. The transition is
written only when the relay accepted it -- leaving the row alone is what
makes the retry correct, because `previous` survives and the delivered
message reads "ok to broken" rather than "broken to broken". The weekly
summary passes `state=None` so a report cannot mark an un-announced
transition as announced.
M17. $5.40 of reader questions took the whole box DEGRADED, mailed "zamuja",
then "spet deluje" at midnight UTC, and exited 1. `api.ask` caps each company
at $2.00/day, so $5.00 is under three customers using the product as
designed: the threshold could not separate a fault from success. It reports
the number and stays OK, the same trade the privacy-notice and off-box-backup
checks already make.
One test was rewritten rather than accommodated: `test_a_failed_send_is_not_
recorded_as_notified` asserted the stored row to prove that "the state still
advances", which is the bug it is named after -- the fourth recorded instance
of that shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L75798a5Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
7614d62Found by the agent working on the operator alarms, which is why it declined to build a spend alarm on "money spent outside a cap": that condition fires on ordinary use today. Both daily caps read `if company_id and limit and asked_today(conn, company_id) >= limit`, and `routes_app` passes `company_id=None` whenever `user["company_id"]` is unset. That is an ORDINARY state and not an edge -- a fresh account has no company until somebody fills in a profile, and registration is open. So the `and` short-circuited and neither the question count nor the dollar ceiling applied, on the only surface in this product that can spend money. `api/copilot.py` had it in all three of its checks, including the per-turn one inside the loop. `ai_questions` has carried `user_id` since it was created, so the row to count was always there and nothing queried it. `ask._payer` decides whose budget a question comes out of -- the company where there is one, because a firm's budget is shared by its people, otherwise the reader themselves -- and a request that cannot be attributed is now REFUSED rather than allowed uncapped. Unreachable through the product, which is exactly why it must refuse rather than be assumed. The count `/vprasaj` shows the reader takes the same scope, or the screen would say "3 od 30" while the refusal fires. An index for the user-scoped lookup sits beside the company one, inside the migration tail. One existing test called `answer()` with neither a company nor a reader -- a shape production never produces -- and now supplies one. Its subject is unchanged. Guards: behavioural (a reader with no company hits the ceiling and is refused), plus a scan over the source of BOTH model surfaces for a cap gated on `company_id`, because this was written twice and the second copy is what makes it a shape rather than a slip. Verified by reintroducing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
af199bfReported by the user as fonts being inconsistent across the UI. Measured by
rendering 18 real signed-in screens through the product's own registration
flow and reading computed styles in Chrome: Inter everywhere, except Arial on
16 of the 18, nine elements each.
Two causes, and the second is the one that mattered.
A `<button>` does not inherit a font. Chrome's UA stylesheet sets
`font: 400 13.333px Arial` on it, and only controls carrying `.btn` are saved
by that class's own `font-family:inherit`. So `.tour-skip`, `.chip-sel
button` and the walkthrough's step list rendered in Arial beside Inter.
And a `font:` SHORTHAND whose family slot is `inherit` is invalid, so the
browser drops the WHOLE declaration -- size, weight and leading with it. That
one was found by the agent working on the search palette; I re-measured it
on two pages outside its file set before acting, and the worst instance is
`/razlicica`:
.ver-id .no declared font:800 26px/1 inherit
computed Inter 16px, weight 400
The comment two lines above it explains that the v-number is set in the body
face rather than the monospace one -- describing an intention the browser had
never carried out. `.scope a` on `/dnevnik` was the same, 12.5px/600 asked
and 16px/400 rendered.
Measured after: Arial appears on none of the 18 screens.
The general fix -- `button,input,select,textarea{font-family:inherit}` in
`theme.BASE`, which would make the first class impossible -- belongs in a
file another agent currently holds, and the scan for the shorthand across
every sheet is being written there too. Neither is duplicated here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L70e56e1L5 from `docs/14-review-2026-09-05.md`, and it is NOT fixed -- deliberately. This makes it loud instead. `ensure_requirements` is idempotent on `(notice_id, extraction_model)` where `extraction_model` is `RULES_VERSION`. The day that constant becomes v2 the lookup finds nothing, ten fresh rows land beside the ten already there, and neither reader is version-scoped: `for_notice` lists all ten grounds twice and `eligibility_summary` counts every qualification, so the chips go 8/1/1 to 16/2/2. Both honest repairs cost something only the owner should spend. Retiring the superseded generation is one DELETE and no migration, and it discards the record of what this product told a firm under the old rules -- which CLAUDE.md calls our liability protection. Scoping the readers keeps the trail and changes `eligibility_summary`, which IS the four-state model, and the working agreement says to ask before changing that. So the property held is narrow and self-disarming: while neither reader filters on `extraction_model`, the version may not move. Do the scoping and the test stops constraining anything, with no edit to it. A second test pins its own premise, so the constraint cannot quietly stop applying if the write stops keying on the version. Verified by bumping the constant to v2 and watching it fail with the explanation attached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
f085355L10 from `docs/14-review-2026-09-05.md`, and the user chose which half was wrong. The overview's deadline band read "Rok se izteka" over `closing_soon_total` -- every open tender the firm has saved or matched, at ANY deadline. So the head said "5 od 34" while three were actually closing that week, and a reader concluded thirty-four of their tenders were running out. The count was already right for what the band SHOWS. The 2026-09-04 change here replaced `saved_closing + matched_total`, a sum of two different sets that double-counted a tender in both, with exactly this predicate -- and left the title describing the older idea. Decided 2026-09-10, by the user: the HEADING was the wrong half. "Najbližji roki" now. Bounding the band to seven days instead would have emptied it on a quiet week, and "what am I working on" is the question an overview answers. The empty state moved with it: "se ta teden ne izteka" described a window this band never had. The email keeps "Rok se izteka" and should -- those reminders fire at 7 and 3 days and are urgency by construction. The guard is over the property rather than the wording: a band head may not use a word claiming a deadline is running out while its total is the unbounded set. Verified by putting both strings back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L
96f34a7G9 from `docs/14-review-2026-09-05.md`. The rule is CLAUDE.md's: psycopg
reads a bare `%` in a query string as the start of a placeholder the moment
the statement is given any parameter, and it cost a live 500 on the tender
page once already.
The guard tested `text.lstrip().upper().startswith(KEYWORDS)`, which is a
statement about how a string LOOKS rather than where it goes. Two shapes were
therefore invisible:
* a statement whose first line is an SQL comment -- and a comment is
precisely where a per-cent sign gets written, being the only place in a
query where prose belongs
* every fragment a statement is assembled from: a WHERE clause appended in
a list, a JOIN built in a branch, an ORDER BY interpolated from a
whitelist. None of those start with a keyword.
Both are covered now: leading SQL comments are stripped before the keyword
test, and the scan additionally walks the first argument of every `execute`
call and takes every string inside it, however it was assembled. What matters
is not how a string reads but whether it reaches psycopg.
Measured: 308 strings scanned before, 357 now. Verified by planting one of
each shape and watching the guard name both, then removing them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4La44009cThree more from `docs/14-review-2026-09-05.md`, each verified by putting the
defect back and watching the new guard fail.
L6. The ranking condition was written twice. The LIMIT/OFFSET pair asked "is
this the fit sort"; the block that actually ranks asked that AND "is the set
small enough to rank in Python". So above `RANK_CEILING` the query took the
ranking path's paging -- OFFSET 0, LIMIT 1200 -- while the ranking was
skipped, and every page of `/ujemanja` served the same first rows. CLAUDE.md
says that bound "is NOT silent"; it was not silent about the ORDER and said
nothing at all about the paging. `total` is known before the rows are
fetched, so the two halves now agree by construction rather than by being
kept in step, and above the ceiling the deadline order pages properly.
L11. The bid checklist hand-copied the Slovenian plural rule for the fourth
recorded time: `f"{len(docs)} datotek"` and `f"{left} dni"`, both the
genitive plural unconditionally, so a pack of one read "1 datotek" and a
window two days out read "še 2 dni" -- on the one screen a firm works
through before bidding. Only one to four decline.
The forms for "dan" are now shared rather than re-typed, because
`views._in_days` renders the same noun on every card and the two arrived at
it separately; the rule was already shared and the vocabulary was not, which
is how a product says "3 dni" on one screen and "3 dnevi" on the next. The
guard derives from the constant, so a third renderer is caught without
anyone remembering. "datoteka" stays at its one call site, per the same rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4Leedd694Both are from the "blind guards" layer of `docs/14-review-2026-09-05.md`,
which the review calls the highest-leverage part of it, and both were proved
by putting the defect back.
G10. `TestTheSalesPageAndTheProductAgree` read `api/views_tender.py` as text,
looked for the literal "Pogoji še niso izluščeni", and SKIPPED when it was
not found. So an ordinary copy edit silently disabled the only check tying
the public sales page to what the product admits to its own users, and the
landing page could then claim extraction runs with nothing failing.
`CONDITIONS_NOT_EXTRACTED` is the definition now: reword it and the guard
follows, delete it and the import fails loudly. Verified by doing exactly
what the review described -- rewording the sentence AND setting Razčlenitev
to LIVE -- which now fails and previously skipped.
G8. The demo's safety sweep walked `routes_app.router` alone, and three POST
routes are declared on the app in `api/main.py`. Two of them mail the
operator, and neither was ever scanned. The `safe_url` needle had also
stopped firing, because `/profil/predlog` delegates to `api/onboarding.py`;
it passed only because that path is in BLOCKED anyway, so the needle was dead
and the guard looked alive.
Three changes, each verified by reintroduction:
* the sweep walks `[*app.routes, *router.routes]`, the idiom two other
test files already use for this FastAPI version's opaque router node
* it follows one level of delegation, and resolves a name imported INSIDE
the handler -- `api/main.py` imports `call` locally, which is why `/klic`
tripped nothing at all
* `demo.ALLOWED`, because the class is called EveryDangerousRouteIsDecided
and offered exactly one decision. `/prijava` and `/klic` mail the
operator at a fixed address, which is not the act `/e-posta/preizkus`
was refused for, and refusing them would discard the demo's own call to
action. The exemption is argued in writing rather than being an omission.
A new test fails if any needle stops matching every route, so a dead needle
cannot pass by describing a danger the app no longer has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4Ldc9bee4Six defects from `docs/14-review-2026-09-05.md` plus one the fixes found,
each reproduced against the running system before it was touched and each
guard verified by reintroducing the defect and watching it fail.
The one that was not on the list is the worst. Every key in `browse.SORTS`
ends on a heavily tied column -- Slovenian buyers set one closing hour for a
whole day, so a 227-tender feed carries 38 shared deadlines in groups of four
to nine -- and a page is its own query. Walking the pager as a reader
clicking "next" does: 227 reported, 227 collected, 219 distinct. Eight shown
twice, eight never shown at all, on the product whose harm is hiding biddable
work. The tie-break is qualified, because `matched_for` joins a table with
its own `id`, and appended in one place so a sort added tomorrow cannot be
the one that forgets.
It surfaced through the spreadsheet rather than the screen: a new test
comparing `/razpisi.csv` to the page it is taken from failed on row two.
M8 the export dropped the suitability order its own link asked for
M11 the bid checklist asked a PDF question about every file (0 open tenders
affected today, said out loud rather than inheriting the review's local 9)
M3 the public demo told every visitor its intake was dead: 30 hours of rope
against a 168-hour cadence, 138 hours of every 168 reading BROKEN
L7 1,483 of 2,062 match reasons named a CPV the tender does not carry
L8 "100 %" over a TED-only denominator that excluded the wedge
L9 "58 %" typed into the sales copy in the present tense
Two guards were failing on a database that is merely old rather than on a
defect, so `needs_corpus` now takes a slice name and the tour sweep tells a
step that never reached its screen from a missing anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WykhBiRawwkKKWAWxjgt4L029e866The commit messages carry the detail; this is the rule. Three things worth a session's attention, and the first is the transferable one: A LOOP OF SUCCESSFUL RUNS IS INVISIBLE TO EVERY CHECK THAT ASKS WHETHER A RUN WORKED. Every per-source check was green and "failures (7d)" read 0% while the box crawled a public register 43 times in a day, because each run succeeded. `run rate (24h)` is the only check that asks how many, and it reported BROKEN the moment it existed. pdfinfo describes PAGE ONE, so a first-page size check is a pre-filter and never a bound -- the file that killed the box says A4 and carries 248 MP pages. Claim the work before doing it and commit the claim, or a SIGKILL leaves no evidence and the next run dies on the same row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
f7542deMeasured after shipping the per-page fix, against the document that was actually killing the box. Two corrections, both of them to claims I had just written. ⚠️ THE MEGAPIXEL CEILING READS PAGE ONE AND I DESCRIBED IT AS A BOUND. `3-3 NACRT-TK.pdf` reports `595 x 841 pts (A4)` and sails past a 40 MP ceiling -- while its later pages are 248 MEGAPIXELS. `pdfinfo` describes the first page, and a Slovenian pack routinely staples A4 text and A0 sheets into one file. So the ceiling is a cheap pre-filter on a file that OPENS on a drawing, and the thing that actually holds is the per-page loop plus Pillow's own decompression-bomb check at ~179 MP. The comment says that now. Proven on the VPS rather than argued: rendering all 29 pages exits 137, the OOM killer; rendering one at a time completes. ⚠️ AND FIVE PAGES WERE SKIPPED IN SILENCE. The first fixed run read 24 of 29 and said nothing about the rest, because the per-page failure was `log.debug`. That is the collapse CLAUDE.md names -- "we could not read this file" arriving as "this file states nothing" -- reintroduced by my own except clause on the same day. Refusals are counted and logged at WARNING, with a line naming how many of how many pages were read. `grayscale=True` renders one byte a pixel instead of three from the moment the page exists. It costs nothing in recognition, because tesseract greyscales and binarises internally anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d443935Found while syncing the demo, by asking why a background job had stalled. It
had been running for 23 hours and every health check was green throughout.
WHAT HAPPENED. `_from_scan` called `convert_from_path(path, dpi=200,
first_page=1, last_page=MAX_OCR_PAGES)`, which returns a LIST -- every page
rasterised before any is read. A Slovenian pack is full of načrti on A0
sheets, and an A0 page at 200 dpi is 6620x9360 px, about 186 MB as RGB. The
`image.close()` inside the loop could not help; the whole list already
existed. Measured on the VPS: thirteen OOM kills at ~3.0 GB RSS between 08:31
and 15:50, on a 7.9 GB box with no swap, always the same drawing.
⚠️ AND A WORKER DEATH IS A FRESH FULL CRAWL. `restart: unless-stopped` plus
`INGEST_RUN_AT_START` means every kill starts the boot ingest from the top. So
an out-of-memory error in the LAST stage of the night became a loop:
| source | runs in 24h | intended |
|---------------|-------------|----------|
| enarocanje | 43 (+9 interrupted) | 1 |
| enarocanje_qa | 38 | 1 |
| ted | 53 | 1 |
| document_text | 43 + 37 interrupted | 1 |
Roughly 46,000 requests to www.enarocanje.si in a day against a design intent
of about 1,200. The data was never at risk -- every ingest is idempotent --
but the harm here is not to us, it is to a public register we have promised to
be conservative with, which CLAUDE.md calls the half that actually matters.
THREE FIXES, EACH FOR A DIFFERENT LAYER.
1. `_from_scan` renders ONE PAGE AT A TIME, so the resident set is one page
whatever the document is, and refuses a page over MAX_OCR_MEGAPIXELS. A0 is
62 MP, A1 31, A4 3.9 -- the ceiling admits everything that could carry
conditions and refuses what carries geometry, which docs/04 already records
as never being the conditions anyway. It costs one pdftoppm invocation per
page instead of one per document; against 82 seconds a page of tesseract
that is noise.
2. ⚠️ THE ATTEMPT IS CLAIMED BEFORE THE READ AND COMMITTED.
`mark_ocr_attempt` ran after `read()` returned, in all three outcome
branches -- which covers every way the READ can fail and no way the PROCESS
can die. A SIGKILL reaches no `except` and commits no transaction, so the
next run selected the same document first and died on it again: a poison
pill, and the engine of the loop. This is the fix that means the next thing
to kill the process costs one document instead of a day.
3. A `run rate (24h)` check, because NOTHING NOTICED. Every per-source check
was green, `failures (7d)` read "2 of 784 runs failed (0%)", and the
operator mail said OK -- because each individual run SUCCEEDED. A loop of
successful runs is invisible to every check that asks whether a run worked,
which was all of them. This is the only check that asks how MANY.
⚠️ TWO TESTS PINNED THE MECHANISM WHILE THEIR DOCSTRINGS STATED THE PROPERTY,
and both had to be rewritten rather than accommodated.
`test_every_outcome_records_the_attempt` required `mark_ocr_attempt` at three
call sites. Its docstring says "missing any one of them leaves that outcome
re-running forever, which is the bug itself" -- and one claim before the read
satisfies that property strictly better than three after it, because it also
covers the kill. It asserts the ordering now, by AST, plus the commit.
`test_an_interruption_is_not_counted_as_a_failure` read
`source.split("def check")[0]`, and ingest/status.py has no `def check` -- the
checks are built inline in `gather()`. So `[0]` was the whole file and the
test forbade the string 'interrupted' ANYWHERE in the module, which is far
stronger than its stated property and would block any check that reports an
interruption -- the fingerprint of a killed worker, and the single most
diagnostic fact nobody could see today. It asserts the seven-day query now.
All four guards verified by reintroducing the defect: batch render, post-read
claim, folding interrupted into the failure count (in a form that keeps the
`status = 'failed'` literal, so the new AST clause is what fires), and the
untranslated check name in the operator mail.
STILL OPEN, deliberately not fixed here: a worker restart re-runs the whole
boot ingest, so every deploy costs a full crawl. That is a cost rather than an
incident, and bounding it is a change to the scheduler's contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF8bfadafBoth failures were mine and both were real. THE DELETE ROUTE REACHED scrypt WITH NO CAP. `api/throttle.py` exists because `accounts.authenticate` spends 33.5 MB per attempt whether or not the account is real, so an unthrottled POST that reaches it is a memory-exhaustion vector before it is a guessing one. `/racun/izbris` re-enters the password on purpose, so it reaches exactly that -- and a session is not a licence to spend the box's memory in a loop. `test_no_scrypt_route_is_left_uncapped` derives its list from the source and named the route within a day of it being written, which is what it was built for. It may say "preveč poskusov" plainly, unlike the login form: the session already names the account, so there is no enumeration oracle to protect and a silent refusal would only look like a broken button. ⚠️ THE WAITLIST GUARD LISTED SPELLINGS AND MISSED THE ONE IN USE. The user reversed the de-anglicisation on 2026-09-04 and the guard written that day named three spellings by hand -- and not `Čakalni seznam`, which is the form BOTH pre-existing sites used: the landing signup eyebrow and the footer link in `theme.py`. So the guard shipped red-but-green, blind to the only offenders that existed, and only fired when I happened to write the lowercase form into the privacy notice. Slovenian declines, so `čakalnega seznama` would have walked through it too. It scans the stem `čakaln-` case-insensitively now and reports file and line. Nothing in this product legitimately says that about anything else. Verified by reintroducing a capitalised AND declined form, which the old guard could not have seen. `CLAUDE.md` calls the hand-written list this codebase's most common defect shape; this is one inside the guard against a reversed decision. Four sites now say "waitlist": the signup eyebrow, the footer, the privacy notice's heading and its lawful-basis sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
3077206/zasebnost, /pogoji, /privacy and /gdpr were all 404, the footer carried no legal link, and the registration form said nothing about personal data -- while registration is OPEN and the account holds a tax number, NAMED EMPLOYEES who never signed up for anything, uploaded certificates, and ten declared answers about criminal convictions and unpaid taxes. ⚠️ THE INVENTORY IS DERIVED FROM THE SCHEMA, NOT FROM A TEMPLATE. That is the whole reason this is worth writing rather than downloading: a template describes a product nobody built, and the hard part of an honest notice is knowing exactly what is stored. Every entry in `privacy.COLLECTED` names real tables, and `tests/test_privacy.py` fails if a table holding personal data stops being described -- so the page cannot be right today and silently wrong in four months when somebody adds a table. Two categories are called out rather than buried, and a test enforces both: the declared criminal-conviction answers, and the named personnel a firm types in -- for whom the FIRM is controller and we are the processor. The notice says so. ⚠️ IT FAILS CLOSED. A notice that does not name the controller fails at its first job, and serving one would create the appearance of compliance without it -- worse than the 404 it replaces, because a reader who finds a page stops looking. /zasebnost answers 404 while the controller variables are unset, the footer link is not rendered (a link to a 404 tells the reader the answer exists and then withholds it), and `ingest.status` reports NOT PUBLISHED on every run -- OK rather than DEGRADED, because a nightly alert about an unmade decision is the alert that gets a filter rule written for it. Same posture as STATUS_TOKEN serving 503 and REGISTRATION_OPEN defaulting shut. Four environment variables publish it. They are in .env.example. Processors are named GENERICALLY by the user's decision: a notice naming a provider that changed is the stale claim this codebase keeps finding, and recipients stay identifiable on request, which the page says. ⚠️ NOT LEGAL ADVICE AND NOT WRITTEN BY A LAWYER, and the module says so at the top. What it is: an accurate description of the system. The lawful-basis wording in particular wants review by somebody qualified. ⚠️ The long-dash guard added this morning caught an em dash I had just written into the processor list. It has now paid for itself twice in a day. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
7a30151Registration is open and the account holds a tax number, NAMED EMPLOYEES who never signed up for anything, uploaded certificates, and ten declared answers about criminal convictions and unpaid taxes. A reader could delete one reference, one file or one saved search, and had no way to leave. That is not a missing convenience; it is the right to erasure absent from a product collecting Article 10 data, on a box where anyone can register. `accounts.delete_account` erases it, and THE ORDER IS LOAD-BEARING because each step exists for something the one before cannot do: 1. The BYTES, first and through `vault`. Nothing cascades a file, so SQL alone takes the row and leaves the certificate on disk -- the defect this codebase shipped in `seed_demo._wipe` and again in `evidence.remove` earlier today. Scoped by COMPANY, because `vault.for_company` returns everything the firm holds including what hangs off nothing, and walking owner columns would be a hand-written list a fifth owner escapes. 2. The company: sixteen tables cascade from it. 3. The user: cascades email_sends, which carries the address. 4. ⚠️ The waitlist, which is NOT linked to the user by any key. Somebody who joined the list and later registered would otherwise have their address outlive their own deletion. Erasure means the address, not the row that happens to point at it. It does not commit -- the caller owns the transaction, so a failure anywhere leaves the account whole rather than half-deleted, and a half-deleted account is worse than either outcome because the reader has been told it is gone. ⚠️ The password is re-entered, because a signed-in session can be a borrowed laptop and this is the one control with no archive behind it -- unlike a dismissed match, which has a Skrito door. ⚠️ NOTHING is written to `activity`. Every other write in that file records one, and a row there carries the address in `actor` with a network prefix beside it: it would outlive the erasure it describes, which is the opposite of the feature. Measured across ten tables plus the disk: all populated before, all zero after, the file gone, and no files left under the storage root. Verified by reintroducing the SQL-only version and watching the test name the certificate still sitting there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
e3daf8dSynced after the gap stopped being cosmetic -- two batches had changed signed-in screens while the demo sat on v283. No schema change between v283 and v295, but the migration was run anyway: it is idempotent and the demo has its own database. ⚠️ The container names are the only thing that says which project a compose command is actually in, and they read tenderwatch_demo-* throughout. That is the check that caught a previous session running a demo command against production. Verified inside the container and over HTTP: version 295 d875386; CPV group 4 in both modules with road and school separated; the value bands partition on the demo's own data (58 banded + 454 unstated = 512); no `name="naslov"` left on /e-posta and the new copy rendering; `?value=small` gives 2 zadetka against 512 unfiltered with the note shown; ANTHROPIC_API_KEY, SMTP_PASSWORD and STATUS_TOKEN all absent; /nadzor and /api/health failing closed at 503; one Traefik backend per stack. ⚠️ Every demo.BLOCKED POST refuses by MIDDLEWARE rather than bouncing to sign-in, and the difference is invisible in the status code -- all four answer 303. Only the Location header distinguishes them: /e-posta/preizkus redirects to "V predstavitvi ne posiljamo e-poste", not to /vpis. Check the header. The cost, and the reason this is not done casually: the restart triggered a boot ingest, TED took 382 notices and the portal run followed. That is a real crawl against a public register, which is why a demo sync should ride along with a change a visitor can see rather than happen for a version number. The pill reads "Zajem tece" now and per M3 will read "ne tece" again within about 30 hours, because STALE_AFTER is 30h and the demo ingests weekly. Still unfixed, and the thing a prospect is most likely to notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
6c57f58Full suite green before shipping. Verified inside the container: CPV group digits 4 in both modules, and 45233120 vs 45214200 now separate as 4523 vs 4521. The value bands, measured on PRODUCTION rather than locally: 601 open, 2 + 37 + 31 banded and 531 stating no value, which is 601 exactly. Before, every band admitted all 531 value-less tenders, so each returned nearly the whole feed under a label claiming a range. ⚠️ This batch changes what a customer sees more than anything else shipped today. The default order on /ujemanja moves, because the ladder's top rung now fires on about a fifth of the feed where it fired on half. That is the intended effect and it is measured, but it is the thing to look at first if anyone says the ordering feels different. ⚠️ And the demo gap has stopped being cosmetic. Two batches now change signed-in screens, so the standing reason for leaving it on v283 -- a redeploy costs the portal a boot ingest -- still holds but the demo should be synced before it is shown to anyone. No live defect from the review remains, and no Medium a customer would notice. What is left is operational or latent; the two with real teeth are M3 (the demo's intake pill reads "ne tece" for 118 of every 168 hours, because STALE_AFTER is 30h and the demo ingests weekly) and M8 (the Excel export drops the suitability order its own comment forbids it to drop). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d875386Both `ranking.group` and `bridge._family` justified three digits with the same
sentence -- "45 alone would call a school refurbishment a road reference" --
which is true of 45 and EQUALLY TRUE OF 452. 45233120 (roads) and 45214200
(gradbena dela na solskih stavbah) are both 452, so three digits did exactly
what the comment said two digits would do. On the top rung of the ladder,
printed on the row as "Imate referenco v isti skupini CPV".
Measured on the open feed for a firm whose one reference is a road:
2 digits 96/96, 3 digits 51/96 (what shipped), 4 digits 21/96, 5 digits 9/96.
FOUR is the smallest change that makes the existing promise true. Five was
considered and refused: it would also drop water mains, pipelines and heating
stations, and in Slovenia those are the SAME trade as road building. Five
would be a new opinion about what a contractor does; four is the documented
one. The seeded firm now fires on 24 of 348 rather than half the feed.
BOTH TESTS PINNED THE DEFECT, one in the purest form yet.
`test_it_is_the_group_not_the_division` explained that 45 "would call a school
refurbishment a road reference" and then asserted `_family("45214100") ==
"452"` -- and 45214100 IS a school building. Docstring and assertion stated
opposite properties. Rewritten, plus a new test that the two modules agree:
a rung on the row and an empty list on the page it opens are two answers to
one question.
The value bands did not partition. `estimated_value IS NULL OR (band)`
admitted every value-less tender into every band, so with 308 of 348 stating
nothing, "Do 40.000 EUR" returned 309 rows of which ONE was under 40.000 and
all twenty on the first page rendered a dash. The three summed to 964 over a
set of 348.
The escape is correct where it came from -- `matching.value_matches` keeps it,
because a band set once on a PROFILE would otherwise hide five tenders in six
for ever. It is not correct in a control the reader clicks now and reads as an
answer. Two questions wearing one clause. They partition exactly now:
1 + 22 + 17 + 308 = 348, which is the property the deadline windows twenty
lines up already had.
And an honest band trades a false label for a misleading emptiness unless the
screen says so, so a chosen band carries one quiet line naming the way back.
No figure in it: that would be a second count to keep true.
Also M6, in the same sentence I was already correcting: the page rendered
"N od M vasih referenc" from `len(references)`, a LIMIT 5 display slice, so a
firm with nine references of which eight matched was told 5 od 9. And it
printed a literal five-x mask, correct only at three digits, which at four
would have rendered a nine-character CPV code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF95accdaCLAUDE.md has carried "lixum.agency cannot renew its origin certificate, expires 2026-10-12" since 2026-08-25. Re-measured today: it stopped being true around 2026-08-30. The original diagnosis was right when written -- the zone is orange-clouded, so TLS-ALPN-01 cannot reach Traefik. What changed is the CHALLENGE. Traefik now runs exactly one resolver and it is DNS-01 via Cloudflare with CF_DNS_API_TOKEN set, and DNS-01 proves control with a TXT record, which is orthogonal to whether the A record is proxied. Measured rather than inferred: one resolver holding all eight certificates; `demo.tenderwatch.lixum.agency` issued 2026-08-31 09:24, AFTER this container started at 19:48 on the 30th, so it came from this exact config; that host is a subdomain of lixum.agency, so issuing it required writing a TXT record in that zone; and the token lists that zone as active. Renewal is due about 2026-09-12. ⚠️ chat.lixum.agency is GREY-clouded, not orange. The old entry guessed it "wants the same check" and it never did. ⚠️ Renewal itself is still unproven because it has not happened yet. The handoff carries the acme.json reader to confirm with after 2026-09-12; a notBefore in September is the proof. The lesson is this project's own: a dated risk nobody re-measures decays like any other claim. Six days of "there is an outage coming" over something already fixed by somebody changing a flag. Off-box backups: deferred by the user, and written up as a complete runbook rather than a reminder. Every tool the script needs is present, but the box has NO gpg keyring and NO outbound ssh identity at all -- `~/.ssh` holds `authorized_keys` and `known_hosts` does not exist -- so it is a setup job, not a config line. Dumps are 169 MB each, 14 days off-box is ~2.4 GB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
18e165dFull suite green before shipping. Five commits, four touching production code, all verified from inside the container rather than from deploy output: the mail relay reads `naslov` in zero places and the form offers no address field, `delete_for_owner` has two call sites where it had none, `paperwork` has zero pictographs in EMITTED strings, and `—` appears nowhere in `api/`. ⚠️ That paperwork number is worth stating precisely: `grep` finds seven warning signs in the file and every one is a comment marker. The AST scan ignores comments and docstrings by design, which is the whole reason it can be trusted where a substring scan could not. The demo stays on v283 for the reason already recorded -- restarting it costs the portal a boot ingest. Noted in the handoff that M9 does change a screen, but `/e-posta` is a signed-in settings page and the demo's audience has not signed up. No live defect from the review remains. What is left is coverage gaps and the Medium list, of which M4 (the ladder's top rung fires on 51 of 97 matches because a 3-digit CPV group does not separate roads from schools) and M5 (the value bands do not partition: "Do 40.000 EUR" returns 309 of 348 rows, one of them actually under 40k) are the two a customer would notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
38470f9Three `—` entities stood in `views_ask.py` and `views_copilot.py` and `/app` served one every day, because `test_copy.py` walks the AST for the literal character and an entity is a different string carrying the same glyph. CLAUDE.md has recorded this hole since 2026-09-04 without deciding whether the entity was an accepted idiom or an unnoticed instance. Decided with the user: unnoticed. The rule is about the reader, the entity renders as exactly the banned character, and this guard's own failure message tells the author to use "a comma, a colon or a full stop" -- none of which an entity is. Copy chosen by the user: the legal disclaimer becomes two sentences, and the assistant's opening hint becomes "Odgovarjam samo iz podatkov, ki jih imamo. Povem tudi, kadar cesa ne najdem." ⚠️ The guard catches the NUMERIC forms too, which the review did not mention. `—` and `—` are the same character again, and naming only the friendly spelling would be the identical hole one step along. Verified by reintroducing all three spellings. That closes the last LIVE defect from the guard review. G5, G8, G9 and G10 remain as coverage gaps rather than faults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
3a067ae`api/paperwork.py` opened its incompleteness warning with a warning-sign emoji, on the A4 a contractor signs -- drawn by whatever font the reader's machine happens to have, at a colour and weight nothing here chose. `tests/test_icons.py` could not see it: `pages()` is seven hand-picked fragments and `/listine` is not one of them, while the identical character in any of the seven fails instantly. Dropping the glyph alone would have made it WORSE. `.pw-note` is small muted grey, so the emoji was the only thing making that sentence loud, and CLAUDE.md is explicit that missing data is SHOWN because "a form with a quietly blank field is a form somebody signs without noticing". The warning is its own field on `Doc` now, with its own treatment: clay, which is ACT_FIRST and means exactly this -- something to do BEFORE signing -- bordered and bold, and forced to black ink and a black rule in print so it survives a printer that drops colour. ⚠️⚠️ MY FIRST FIX FOR THE GUARD WAS WORTHLESS. I added the paperwork renderer to `pages()` and fed it a `Doc` built in the test; then I reintroduced the emoji and the suite stayed green, because a fixture supplies its own clean text and can never surface a string that lives in the module. That is the trap CLAUDE.md names -- a derived path with a typed parameter is a smoke test that renders the empty state -- and I had read that entry earlier the same day. The replacement reads the SOURCE, which is safe because the AST yields only real string constants: comments never appear and docstrings are skipped, and those two are where every legitimate warning-sign note in this codebase lives. Verified by reintroduction; it names the line. ⚠️ Two flags are NOT defects and are argued rather than quietly excluded. A warning-sign inside a CSS COMMENT is prose reaching nobody, so `/* */` is stripped for the same reason the rendered scan strips `<style>`. And the mail modules are out of scope: Outlook renders with Word's engine and cannot draw inline SVG, so `opsmail.py` KEEPS its typographic marks -- a guard failing those would demand a shape the medium cannot produce. The type-scale guard then caught `.pw-warn` at 13px, which is not a step. It is 14px now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
f36aded`company_documents.credential_id` and its two siblings are ON DELETE CASCADE, so the database row always went on its own -- and nothing cascades a file. A firm that uploaded an insurance certificate and then deleted the credential it hangs off had not deleted the certificate, and no surface would ever have said so: `api/vault.py` records that the 30-day pack pruner deliberately cannot see COMPANY_STORAGE_PATH, and nothing else touches that tree. `vault.delete_for_owner` was written for exactly this and its own docstring asserted it was called. It had ZERO call sites in `api/`. It is called now, BEFORE the row -- once the cascade has run there is nothing left to say which files belonged to it. Measured both ways: with the fix the bytes go, without it the row goes and the file remains. ⚠️ `scripts/seed_demo.py` was fixed for this same defect on 2026-09-04 and the PRODUCT route it mirrors was not, which is the more expensive half -- a demo orphans its own invented files, a customer route orphans theirs. CLAUDE.md records the seed fix and not this one, so the brief read as though the class were closed. ⚠️ One of the three new tests is a CONTROL, and it guards the direction this fix made worse rather than better. `delete_for_owner` now runs on every removal, so a scope bug in it would DESTROY another tenant's paper rather than merely leak it. The control asserts that both `evidence.remove` and `delete_for_owner` are company-scoped, because adding the call enlarged the blast radius even as it closed the leak. The second test covers `reference` as well as `credential`: all three owner kinds go through one `remove`, so a fix that only handled credentials would have passed a single test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
fad76c2`/e-posta/preizkus` took its recipient from the form, so any signed-in account could make the server deliver an HTML message -- our branding, our domain, our SPF and DKIM -- to any address on the internet, as often as it liked, with up to INTRO_MAX characters of its own prose in the body. Registration opened on 2026-09-04, so "signed-in account" means anybody. ⚠️ `demo.BLOCKED` refused this route on the demo box, which is a large part of why nobody noticed it was open in production. A guard that covers the disposable environment and not the real one reads as coverage. REMOVED RATHER THAN CAPPED, decided with the user. A rate limit still leaves a relay, only slower. The route's own purpose -- "send exactly what is in the preview, and mark nothing" -- is served completely by sending to yourself, and the form pre-filled the account's own address already: the editable field was a convenience nobody needs and an open relay everybody gets. Checking rendering in a second mail client is served by forwarding the message you receive, which spends the sender's reputation instead of ours. The recipient is `user["email"]` and is not read from the request at all. MAIL_TEST_MAX stays as defence in depth against a different threat from the login endpoints: not the CPU an scrypt hash costs, but our domain's sending reputation. Measured with `mailer.send` stubbed -- six attempts to mail six strangers produced zero messages to strangers, and eight further requests produced zero further sends. ⚠️ TWO EXISTING TESTS PINNED THE RELAY, both inside a test whose NAME states a different property: `test_a_test_send_marks_nothing` asserted that the message went to an address posted in the request, and that the address showed up in the send log. Neither has anything to do with marking nothing. They were incidental assertions, which is exactly where this shape hides -- and CLAUDE.md already records "a test that pins the bug it is named after" as this codebase's second-commonest defect. ⚠️ One of the four new tests is a CONTROL. A route that sent to NOBODY would satisfy "no stranger receives it" and would also be a broken feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
a3e0cf6Full suite green before shipping -- 2,374 tests, 0 failures -- and the test_run_extract advisory-lock flake did not recur, which is worth writing down: it is intermittent rather than constant, so a future session should not assume a red run there is always the lock. Verified inside the container: the deployed fetcher carries the new pacing and ZERO occurrences of the old `time.sleep(args.delay)`, so tonight's crawl is the fixed code. ⚠️ The demo is deliberately left on v283. Restarting it triggers a boot ingest, so syncing a version number would cost the portal a full extra crawl -- and the demo runs DOCUMENTS_ENABLED=false, so the fetcher never runs there anyway. Nothing in v284-v287 is visible to a demo visitor. This commit is DOCS ONLY and is not being deployed for the same reason: restarting production to publish a paragraph is not worth the boot ingest. Local is one commit ahead of the box on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
fefb654`extract/run_documents.py` pulls tender packs measured at up to 46 MB from www.enarocanje.si, and it was unpaced in two directions at once. `fetch_for_notice` looped over a notice's pack URLs with NO pause of any kind between them. And the caller's `time.sleep(args.delay)` sat at the foot of the happy path, where three `continue`s skipped it -- an exception, a refusal, an empty result. So a night on which every candidate failed or was refused issued up to `--limit` (default 100) requests back to back, which is precisely the night the portal is already unhappy. That is the shape CLAUDE.md records as fixed twice already -- in `questions.fetch`, then in four methods of `evidencna` -- arriving a third time in the one package the guard never looked at. Paced BEFORE the request now, which is the idiom `EnarocanjeClient.detail` has always used and the one no `continue` can skip. `delay` is a REQUIRED keyword-only argument, because a default would let the next caller omit it and never find out. The caller's trailing sleep is deleted: notice-to-notice pacing comes free, since the next notice's first pack pauses too. Verified with a stubbed session -- every request preceded by a gap on the happy path, on a refusal, on an error, and when every single pack fails. ⚠️ `--delay`'s own help said "seconds between packs" while the sleep it fed sat between NOTICES. It is now what the name claims. The guard had THREE holes, not the two the review found. `extract/` was not scanned. Membership required `"_pause" in text`, so a module with no pacing dropped out of the list rather than failing it -- absence of the fix reading as absence of the risk. And widening both of those STILL did not reach the defective module: `run_documents.py` contains no request primitive at all, because the socket is opened one module away in `documents.download`, so a scan for `session.get` finds the function that sends one request and misses the loop that sends a hundred. `download(` is in REACHES_HOST now. ⚠️ The placement exemption needed a guard of its own too. A loop containing a request was waved through as "paced against its own next iteration", which rests on "if a request raises, the loop ends" -- true only of a loop with no `continue`. `fetch_for_notice` continues past both a refusal and a RequestException, so a pause at the foot of that loop is skipped by exactly the failure paths that matter. A loop that can continue is no longer exempt. Verified by moving the pause there and watching it fire. EXEMPT is a dict now, so an exemption cannot be added without saying why. Two things this widening raised are genuinely not defects: `load_cpv.load` makes one request per run and then loops over the rows it got back, and `documents.download` is a single-shot streaming helper whose only loop walks chunks of one response. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
5b7592e`INLINE_SOURCES` was eleven lambdas typed by hand -- five scripts and six
stylesheets -- while `tests/sheets.py`, imported at the TOP OF THE SAME FILE
for a different guard, derives 14 scripts and 34 sheets. So the class whose
own docstring says the hazard is live in every inline script on the site
could not see `theme.MENU_JS` or `theme.PALETTE_JS`, the two largest scripts
in the product, both of which `app_shell` injects into every signed-in page.
Proven by the review: a literal closing script tag in PALETTE_JS kills the
palette on every signed-in screen and spills ~10 KB onto the page as visible
text, and 660 tests pass.
Tenth-plus instance of this codebase's commonest defect, found in the file
that holds the meta-guard against it.
⚠️⚠️ AND THE NARROW LIST AND THE OVER-BROAD RULE WERE HIDING EACH OTHER.
Deriving the list immediately failed four scripts for containing `</span>`,
`</div>`, `</p>` and `</button>`. Those are not defects: those scripts
ASSEMBLE MARKUP, which is what they are for. The assertion matched ANY
closing tag and had only ever passed because the typed list happened to
exclude every script that builds markup. Neither fault was visible without
fixing the other.
Measured in Chrome rather than recalled from the spec, because the fix turns
entirely on which sequence terminates the element:
`</span></div>` in a markup-building string script RAN
`</div>` inside a comment script RAN
`</script>` assembled at runtime with `+` script RAN
a LITERAL `</script>` inside a comment DID NOT RUN, and the
remainder spilled onto the
page as visible text
Only an element's own closing tag ends it. The KIND travels with each source
now and the guard asserts `</script` for a script, `</style` for a sheet.
Verified by inserting a literal closing tag into PALETTE_JS's leading
comment: it fires on both `theme.PALETTE_JS` and `views.PALETTE_JS`.
The meta-guard checks USE now, not size. `test_every_scan_covers_every_sheet`
asserted that the derivation RETURNED at least thirty things while its name
promises that every scan COVERS every sheet -- which is how INLINE_SOURCES
sat six hundred lines above it. `test_no_scan_here_reinvents_the_derivation`
asks the real question, and was verified by reverting INLINE_SOURCES to six
entries and watching it report `INLINE_SOURCES has 6 of 48`.
⚠️ My first version of that guard was wrong in the strict direction: it
failed `EVERY_SHEET` and `ALL_SHEETS`, which are correct. A CSS-only scan is
complete at SHEETS -- "a page may not re-declare a shared primitive" is a
property of stylesheets and scanning scripts for it means nothing. The rule
is sheets, scripts, or both, never an arbitrary slice of one. `PAGE_SHEETS`
stays exempt because its own comment argues why widening it reports
non-defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyFbc772b6`tests/test_vault.py` has said since it was written that "most of what is worth testing here is the tenancy boundary, because that is the whole security model". It tested mime types, path containment and root separation, and asserted the boundary nowhere -- nor did anything else in the suite. A review proved it on 2026-09-05 by neutralising `vault.get`'s scope to `(company_id = %s OR true)`: firma B downloaded firma A's certificate over HTTP, 200 and 69 bytes, while 400 tests across ten files passed and a full-suite run of 2,452 came back green. Eight tests now, two firms and a distinctive marker: five at the SQL scope where the filter lives and three over HTTP where the bytes actually leave. Verified by reintroducing that exact defect -- four fail, including the HTTP one that was the original proof. ⚠️ One of the eight is a CONTROL. A scope that returned nothing for everyone would satisfy every other assertion in the file, and that is a broken vault rather than a secure one. ⚠️ AND THE SCOPE IN `get` IS LOAD-BEARING FOR DESTRUCTION, WHICH THE REVIEW DID NOT FIND. `delete` resolves the path THROUGH `get` and then unlinks it while its own DELETE stays scoped, so a widened `get` means a cross-tenant delete removes the BYTES and leaves the ROW. Measured, not inferred: delete returned True, the file was gone, the row survived. That is precisely the "row pointing at nothing" state the comment under the unlink says the ordering exists to prevent, arriving through a door it does not cover. The derived guard went from one module to fourteen. `test_every_company_scoped_query_actually_compares_the_company` derived its FUNCTION list from `api.activity` and then named that one module by hand -- the same hand-written-list defect one level up, in a docstring that boasted about the half it got right. 14 modules, 48 functions, both axes derived; proven by removing the scope from `vault.for_company`. Two things it had to learn to stay honest rather than just louder. The scope is often in a shared CONSTANT and that is the BETTER pattern -- `alerts. FOLLOWED` is the one definition three surfaces compose so the mail and the screen cannot describe different sets -- so a function is judged on its source plus every module-level string it names. And an INSERT supplies the tenant rather than selecting by it; UPDATE and DELETE are not exempt. ⚠️ The fixture's own teardown was the same shape as everything else here. It deleted the company, then `DELETE FROM users WHERE email = %s` with the address the test had generated -- `vault-A-...` -- while `create_user` lowercases it. The delete matched nothing, silently, and 48 accounts piled up with their companies gone, which is exactly why it looked like it worked. By `id` now, the value the database returned. Proven by counting rows either side of a run: 48 before, 48 after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
6c66d01The handoff written earlier in this sitting said "nothing here is deployed", which stopped being true an hour later. Correcting it in the same sitting rather than leaving it: a future session reads that file as settled fact, and CLAUDE.md already records an honesty statement nobody re-measures decaying like any other claim. Both stacks are on v283, verified from inside each container. The evidence for the dashboard fix is PRODUCTION data, not the demo screenshot -- the demo re-seeds hourly, so every match there genuinely is new today and the two numbers agree whether or not the bug is present. On production the two counting bases differ by 12 tenders for one company and 3 for the other. And the phone measurement is repeated LIVE, because the demo banner costs ~60px that the local box does not have: the chips land at y=410-745 there against y=409-526 locally, so the worst case (a 200px title) sits at 745 against an effective fold of ~791. Visible, and only just. The lever if that is judged too tight is the one deliberately not pulled today. ⚠️ The find worth carrying: the full suite failed twice, both times in test_run_extract.py, both times on a held `extract_requirements` advisory lock, while the file passes 38/38 in isolation. Not caused by this sitting -- no commit touched the extraction path -- but it makes a green run and a red run mean the same thing in that file, which is how the next real failure there gets dismissed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
80b09bcA five-agent review, one layer each, every finding reproduced against the
running system before it was written down. 46 findings: 5 High, 17 Medium,
14 Low, and 10 blind guards. This fixes the High ones and the guard hole
that three of them fell through; the rest is written down and NOT done.
⚠️ THE FINDINGS ARE IN THE REPOSITORY, WHICH IS THE POINT. The 2026-08-29
audit produced 64 findings and lost 55 of them. The 2026-09-04 review
produced 58 and left 50 living only as the sentence "Fifty findings
unverified" inside a published artifact -- I read it; they are not in there.
Both sets are gone. docs/review-2026-09-05/ carries all five reports whole,
refutations included, because a link is not a record.
What was fixed:
The overview counted "new today" over `notices` while counting the total
over `open_opportunities`, so the two disagreed about what a tender IS --
a popravek is a separate NOTICE and one OPPORTUNITY. Measured: tiles reading
97 and 107 on one screen, under a greeting saying "Danes: 107 novih
razpisov". It also silently defeated the guard that stops a first match run
greeting a new account with "116 novih", because two numbers counted over
different tables can never be equal. The landing page had this exact defect
(841 against 610) and it was fixed yesterday; this is the same one on the
signed-in front door.
`run_documents` opened an `ingestion_runs` row and returned without closing
it on the "nothing to fetch" path -- the SUCCESSFUL path. `last_good` froze,
the box went permanently DEGRADED, the operator was mailed about a job that
was working, and the next run reported the row as left behind by a dead
process. `run_evidencna --dry-run` did the same through a different door:
`start_run` COMMITS, deliberately, so rolling back the data never removed
the row. That runner also discarded its failure count, so a night on which
every CSV request was refused recorded `ok`.
The digest's per-recipient isolation covered `compose` raising, which is the
exception it had once seen, and not the CLASS. Neither handler rolled back,
and `emailprefs.for_user` -- a database call -- sat outside the guard, so a
database error for recipient k left an aborted transaction that killed the
FIRST STATEMENT of recipient k+1 and nobody after k was mailed. Nothing
reached `email_sends`, so every operations surface showed a clean morning.
And nothing escalated on that, because no check anywhere read `email_sends`.
`ingest.status` now does -- on the failure RATE, never on the absence,
because an empty digest is deliberately never sent and alarming on a quiet
week is how an alert gets a filter rule written for it. (`alerting.py` did
count failures, but only in the weekly summary; a number in a Monday report
is not an alarm.)
`certdates` collected EVERY date within 80 characters of a cue, so a
surveillance-audit date inherited "velja do" and `/dokazila` reported the
paper said 15. jun. 2026 against a certificate whose typed value matched it
exactly -- a wrong date on the field a bid fails on, inside the one feature
whose promise is that a suggestion carries its own evidence. Its ordering
was worse: the sort key was a `find()` for a whitespace-collapsed quote
inside uncollapsed text, so it was -1 for every entry on any real PDF and
the "document order" the comment promised was really CUES-tuple order.
Sixth instance of a comment asserting a property the code lacks.
The guard under three of these asserted that the STRING "db.finish_run("
occurred somewhere in the file, over two typed modules out of ten. It is
two guards now, both deriving the runner list from the filesystem: no
`return` after `start_run` without a `finish_run` on the way, and no
`if/else` where one branch closes the row and the other forgets.
⚠️ That replacement immediately found a third instance the review had not --
`run_extract` returns straight after `start_run`. It is CORRECT: it hands
`run_id` to a helper that closes it. So the guard follows the handoff and
verifies the callee really closes the row, rather than being loosened to
wave the pattern through.
Every fix was verified by reintroducing the defect and watching the new
guard fail, then restoring from a FILE COPY -- `git checkout` reverts to
HEAD and silently deletes an uncommitted fix, which it did to me once.
Deliberately not changed: `browse.saved_closing` joins `notices`, and the
first version of that guard flagged it. Following it into
`open_opportunities` would make a saved tender vanish the moment a popravek
superseded it, because the feed holds the corrigendum under a different id.
Neither join is right and the honest fix needs the corrigendum family.
Measured: 0 saved rows currently fall outside the feed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF619539fMeasured on the live demo across seven real tenders at 390x844: the header
card is 413-748px of an 844px screen and the tab bar takes 53 more, so
"Razlogi za izkljucitev" began at y=614-948. At best the heading peeked in
and NOT ONE state row was ever above the fold. On a desktop the same block
sits at y=381-490 with its rows visible -- this is the phone half of the
reorder that moved it from y~1170 to y~384 on 2026-09-03 and never reached
the narrow layout. The screen where the product's whole argument lives
opened with six ways to leave it and none of the answer.
The count goes in the header card. Measured after, six tenders, local app
with a seeded firm: chips at y=409-526, every one above the fold.
Shown only where the problem is. Above 900px the section head carries the
same string 150px below and two copies on one screen is a duplicate, not a
summary -- so the header copy is display:none there, which also means a
touch laptop does not get it. My first version of that rule was
`@media (pointer:coarse)` alone, which would have resurrected the chips on
exactly that machine; both conditions have to hold.
`state_chips()` is one function because two spellings of a vocabulary
drift. The section keeps its own chips: `tour.py` renders
`qualification_block` standalone, so removing them there would take the
counts out of the walkthrough.
The row links to the section it summarises, which is the idiom the
overview's contents strip already uses. A chip is 24px -- fine to read,
under the floor for a thumb -- so the LINK grows to 44 rather than the
chips.
Two mistakes worth recording. `pkill` did not kill the local uvicorn and
the replacement silently failed to bind, so the old process kept serving
and the new CSS "was not applied" for three probes -- the answer was
`netstat`, and the PID had not changed. And Emulation.setEmulatedMedia
with a `pointer` feature does nothing: `matchMedia('(pointer:coarse)')`
stayed false through it. `Emulation.setTouchEmulationEnabled` is what makes
it match, which turns the previous session's "48 controls under 44px" false
positive into something measurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyFfbdec5cMeasured on the live demo at 390px: the bar wrapped to four lines and stood 84px tall on EVERY signed-in screen -- on the very screens whose recent work was getting something actionable above the fold. After: 60px, and the first section heading moved from y=374 to y=325. What may not be compacted away is the claim. "izmišljeno podjetje" is the sentence that stops a visitor concluding the product invented a real profile, and api/demo.py's whole posture rests on the demo never passing itself off as an account, so it stays in full. Only the FIRM'S NAME is hidden, only on a phone, and only because it is redundant there: the greeting two lines below states it and the account chip carries it on every screen. Both ways onward stay too. And the name ends in a full stop -- "Gradnje Vidmar d.o.o." -- while the template appended another, so every screen of the demo read "d.o.o..". It is decided from the value now rather than by deleting the period, because a name that does not end in an abbreviation still needs one. The guard asserts against DEMO_BAR_CSS. My first version used tour.CSS, which is the walkthrough PAGE's sheet -- a guard pointed at the wrong constant proves nothing about the thing it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
17c8ce5Documentation only. Fourteen demo pages framed at 390px, measured and photographed; no horizontal overflow anywhere, one real defect already fixed, and five findings that were artefacts of the harness rather than the product. The count-up one is the dangerous shape: it looked exactly like the 841-vs-610 defect fixed hours earlier, and animation:none does not stop a JS-driven counter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d446016Found by fetching the demo's real pages, framing them at 390px and looking at the screenshot rather than only at numbers. Below 560px the row read grid-template-columns:1fr auto with the meta in the second track, and .rmeta carries white-space:nowrap from the base rule and max-width:none from the 760px block. So a long buyer name sized the auto track to its full unwrapped width and the title took whatever was left. Measured at 375px: title 137px, then 20px, then 0px against a meta of 589px inside a 339px row. It was invisible until yesterday's clamp, because before that the title wrapped instead -- which is exactly what produced the 522px rows. One cause, two symptoms, and the height one was the less harmful. Everything stacks below 560px now: when, title, meta. Every title gets the full 307px. The phone page grows 3,032 to 3,289px, which is the honest price of a readable title and still 42% below where this screen started. The contents strip also scrolled 599px sideways with nothing saying so, so its last chip read as a truncated word rather than as more content. A mask fades the edge and costs no height, which matters on the screen this work spent its effort shortening. Both guards find the 560px block that carries the row rules rather than taking the first one, because this sheet has several and taking the first is the trap CLAUDE.md calls "read the BASE rule, not the phone override". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d8891c6Measured every tile before deciding, because two of the three oddities were by design: the 104px/43px split is the country strip rendered at the weight of context, and the full-width third tile on a phone is the deliberate fix for three tiles orphaning in a two-column grid. The third was real. The rule read .tiles:not(.ctx) while the country strip is .tiles.nat, and ctx appears nowhere in the stylesheet, so the scoping its own comment promised was never in force. Harmless only by arithmetic: that strip has four tiles and :last-child:nth-child(odd) cannot match an even one. A fifth country number would have spanned the full width, on the one row this page deliberately renders quietly. Fifth instance of a comment asserting a property the code does not have, and the first of them in CSS. The guard derives the excluded class from the modifiers the markup actually renders. Its own first version failed on the already-fixed file, because the regex read the explanatory comment quoting the old selector -- the prose trap CLAUDE.md records four times for Python guards, arriving in a stylesheet. CSS comments are stripped now, and the guard was verified by putting .ctx back and watching it fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
5009f80Documentation only; v276 595b68b is live on both stacks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
595b68bMeasured in Chrome before the change: the overview's rows took six distinct heights at 1258px (46-84px) and thirteen at 390px, from 70px to 522px. One row filling 62% of a phone screen, and the only cause was that a long tender title wrapped freely while its neighbour's did not. .rmeta beside it had been clipped to one line since it was written, and the comment there already stated the rule: a column whose rows differ in height for a reason the reader cannot see is the spreadsheet complaint one level down. The feed settled the same argument with the ledger row; this screen never got it. After: four heights on a desktop and three on a phone, each pair one pixel apart from sub-pixel rounding, so really two of each. The remaining difference is rows that carry a meta against rows that do not, which is a content difference the reader can see. The phone page went 4,430 to 3,032px, and 5,647 to 3,032 across this session -- 46%. One line on a desktop and two on a phone, where the column is about 343px and a Slovenian tender title rarely fits in one. Two is still a fixed ceiling. Nothing is lost: the full string is on the element's own title, the same decision .rmeta took. The test targets the rule rather than splitting on a media query, because views.CSS has several max-width:560px blocks and taking the first is the trap CLAUDE.md records as "read the BASE rule, not the phone override". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
63d97d0Documentation only; v274 58ae8f3 is live on both stacks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
58ae8f3Measured in Chrome at 1258x702 and at 390x844, the phone in an iframe because --window-size=390 lays a page out at 499 and every conclusion from that is wrong. Before: the first section heading sat at y=769 on a 702px desktop viewport and y=980 on an 844px phone, so the only thing above the fold on either was the word "Pregled". A returning reader got a greeting, a setup card and three numbers, and had to scroll to reach anything they could do something about. The page ran 2,710px on a desktop and 5,647px on a phone across eight stacked sections, with the last two starting five and six screens down. After: 2,313px and 4,430px, first heading at y=277 and y=265, and four headings above the fold on both. Three changes. The task group moved above the tiles, because the tiles are a summary of the page and the task band is a call on the reader; the greeting stays first, since CLAUDE.md records that landing on a checklist without knowing where you are was the previous version of this mistake. The two sections nobody reached are folded, with the heading and the count in the closed summary so the reader is choosing not to look rather than not being told. And a contents strip at the top links every section. The index is DERIVED by scanning the rendered markup for the ids band_anchor put there, never a list of label/href pairs beside the bands. A hand-written index is this codebase's most common defect shape and here its failure is a chip that scrolls nowhere -- silently, since nothing tests a link that does not resolve. tests/test_mobile.py caught an ungated :hover on the chips, which on a touch screen sticks after a tap and turns the last one pressed into a permanently highlighted item. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
71914c9Documentation only; v272 7066b01 is live on both stacks. Worth recording that off-box backup needs no code: the script exists, is chained, and ingest.status already watches it. It needs OFFSITE_DEST and a decision about where. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
7066b01Two things, both self-contained and needing no key. The deadline filter only ever narrowed towards the deadline. A reader could ask for more of what is about to close and never for less -- while 204 of 610 open tenders close within 7 days, and the product already held the opinion that this set is not actionable in two separate places: DEADLINE_WINDOWS' own comment calls a tender closing inside a week "usually not actionable for a firm this size", and DOCUMENT_MIN_LEAD_DAYS=7 deprioritises exactly those in the fetcher. Held twice, actionable nowhere, and noticed by the user reading a number on their own dashboard. min7 is the inverse; 151 + 198 = 349, an exact complement. A filter, never a default: defaulting it would silently drop a third of the feed. And company_credentials.valid_until is typed from memory while the tender page and the bid checklist both warn on it, so a transposed digit is silent in the dangerous direction. api/certdates.py reads the date off the certificate we already hold. It suggests and never writes, every suggestion carries the sentence it came from, and a document with no "until" phrase yields nothing at all -- because a certificate carries an issue date, an expiry and often a third number, and every heuristic for picking among them is wrong on real documents. The range form needed its own anchor: "velja od X do Y" with a naive cue would report the START date as the expiry. Three things the codebase's own guards caught while writing it. A literal backspace where \b was meant, for the third time in this repo and the first time a test found it. An ASCII cue spelled without diacritics, refused by the copy guard -- folding both sides is the product's existing semantics and the better fix. And a page-wide substring scan that matched the class name in the inlined stylesheet rather than the rendered element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
4cfe7a8Documentation only. Both stacks run v270 dfc712d and are verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
dfc712ddocker-compose.demo.yaml said, in a comment, "bring it up with an explicit project name" and gave the command with -p tenderwatch_demo. That is a comment asserting a property the file did not have, which CLAUDE.md records as its own defect shape, and it bit today. A `docker compose -f docker-compose.demo.yaml run --rm worker python -m scripts.migrate` without -p defaults the project to the DIRECTORY name, which is production's. It built production's images and migrated production's database, and reported success. Nothing broke: migrate is idempotent and the schema was already applied, production stayed on v268 with one Traefik backend per stack and 6/6 200s. What actually happened is that the demo was never migrated, silently -- which is the half that matters, and exactly what this file's own isolation argument exists to prevent. A top-level name: key makes the project structural, so the flag is no longer load-bearing and the header no longer teaches anyone to type it. tests/test_demo.py fails if the key goes missing or ever matches production's project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
b478417Documentation only; v268 e2db4fa is already live and verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
e2db4faReported as "this button doesn't do anything", and it was not broken: the href was right and the demo answered 200. The demo box runs the WHOLE application, so its root serves the same marketing page with the same hero and the same title. Clicking "Oglejte si sistem" went from a landing page to a landing page on a different domain, which is indistinguishable from nothing happening. /app is the product and the demo needs no sign-in, so the fix is one path segment rather than a modified demo. DEMO_APP_URL is derived from DEMO_URL, so setting that env var to a bare host -- the obvious thing to type -- cannot bring it back. The waitlist is called the waitlist now, in English, by the user's decision. That reverses part of a de-anglicisation copy pass an earlier session made, so it is recorded rather than silently applied. And the FAQ still said "zato registracije se ne odpiramo", which went stale the moment the door opened this afternoon. CLAUDE.md already records that an honesty statement nobody re-measures decays like any other claim; the previous one sat there for months saying matching was in development. contacted_at now has a writer. /nadzor has always shown "N se nekontaktiranih" computed from that column and nothing ever set it, so the pending figure could only ever equal the total and would have reported every lead as untouched for ever. A number that cannot change is worse than no number, because it is read as news. Third instance of that shape here -- matches.dismissed_at had nine readers and no writer, and company_credentials.document_path had none until the vault shipped. It is a POST behind the same token as the page, it fails closed when STATUS_TOKEN is unset, and it toggles, because the commonest correction is having pressed it on the wrong row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
1f3eb67Registration is open on the box and the sales page leads with the wedge. Documentation only; the code is v266 aaf94db, already live. The measurement worth carrying: zero customers, zero waitlist, and an engineering stack with 25,300 notices and 210,484 knowledge chunks behind it. The constraint on this product has not been engineering for some time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
aaf94dbDecided by the user: reopen registration and frame the page on the wedge rather than on extraction. CLAUDE.md said to open the door only once extraction showed requirements against real tenders; that is reversed here deliberately, and recorded as such. The reasoning is measured, not impatient. docs/05-competitor-audit-2026-08 found that citation-backed extraction is no longer a differentiator -- TenderStria, Lucius, Brainial, Tender Zen and Aitenders all sell it. What none of them has is Portal javnih narocil, and that works today: 291 of 610 open tenders are portal-only. The gate was holding back the differentiator to wait for the commodity. The honesty rule is untouched. Razclenitev stays SOON, Odgovor stays PARTIAL, and the guard tying the two surfaces together still fails if this page claims extraction runs while a tender page tells signed-in readers it does not. The old headline -- "veste, kje vas bo ustavilo" -- WAS the conditions step, so it went; the lede no longer has to walk it back. Three defects found on the way, none of which could have reported itself. The waitlist had two doors and only one spoke. Found because the user asked whether there is a backend for people who join it. There is, and it is well built -- but the landing form wrote the row and returned, so a signup was discoverable only by opening /nadzor and thinking to look. Meanwhile /klic, the same table with a different source, mailed the operator, and its notify() docstring states the principle outright: a row in a table nobody reads is not a lead. Written down, applied to one of the two. The front door overstated the feed by 38 per cent. _landing_stats counted notices past their deadline -- 841 against open_opportunities' 610 -- so it was counting 231 corrigenda as separate tenders, which is precisely what current_notices exists to collapse. Invisible until the hero quoted a second number that would have contradicted it two lines below. And the wedge count takes the portal's own SL route code from provenance, never a missing TED number: 49 open tenders say on their own route code that the buyer published to TED while we hold no TED record, and calling those portal-only is a claim we cannot stand behind on the one page where it is the pitch. My first query made exactly that mistake and reported 340. Two notes for whoever is next. The query is a plain string with a named marker rather than an f-string: it contains an empty-array literal, so an f-string stopped the module importing, and the comment written to explain the escaping broke it a second time. And it stays INSIDE _landing_stats because two existing guards read it with inspect.getsource; moving it to module scope hid the query from both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
6bef5ddDocumentation only; the code on the box is identical to d1d8d07. Worth writing down because production had been seven commits behind, six of them from earlier sittings, and because run_company_text reports zero on production for a correct reason -- registration is shut, so there are no company documents yet. A future session reading "the stage is live" should not read it as "the stage has done anything". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d1d8d07Asked whether the AI gets smarter from the firm's own documents. It did
not, twice over. Nothing in the product learns -- matches.dismissed_at is
read by nine queries that all filter the row out and reaches ranking.py
nowhere -- and company_documents was written by vault.py and read by
vault.py, while extract/text.py has read PDF, docx and xlsx since August.
Measured locally: 4 stored documents, 0 readable, against 90 credentials
and 120 references typed into a form.
The second half was structural. knowledge_chunks is document_id NOT NULL
REFERENCES documents plus notice_id NOT NULL REFERENCES notices, so the
corpus behind /razpis/{id}/vprasaj and the copilot could not have held one
of the firm's own papers. Retrieval knew everything a buyer asked and
nothing about what the reader has.
extract/run_company_text.py closes the reading half: its own ingestion_runs
source, its own lock key, nightly in the scheduler and outside
docs_enabled, with the text reachable from the palette and /isci through a
company-scoped search._company_docs. Proven with two isolated tenants and a
distinctive marker each -- owner sees it, other tenant sees nothing, no
company sees nothing.
Two defects the work found in itself, both by running rather than reading:
A rule transplanted between corpora of different SIZES changes meaning even
when the code is identical. run_text fails a run when everything broke and
nothing came back, which is right for thousands of documents and wrong for
a firm holding two: one customer's corrupt PDF is a 100% failure rate that
takes the box DEGRADED and mails the operator nightly about a file only
that customer can fix. The four local fixtures are stub PDFs and a
perfectly healthy first run recorded failed. What does fail the run now is
every file being gone, which is the missing-volume defect -- and asking
that question needs the file checked BEFORE reading it, because read()
returns None for a missing path and None for a blank page alike.
And test_no_stage_writes_a_run_row_the_scheduler_never_produces read two
typed extract/ paths while its own docstring said "Derived", so
run_chunks.py was invisible to it and so was every runner added after: the
hand-written-list defect inside the guard against it, third time in a test
file. It globs both packages now and reads ON_DEMAND rather than naming it.
READABLE is the intersection with vault.ACCEPTED, computed at import. It
began as a hand-copied line carrying .odt and .ods, which the vault
refuses -- suffixes no query could match, dead entries that read as
coverage. Found by a test asserting the intersection, not by review.
The /dokazila note is silent on success and takes no state colour, sixth
time the four-state palette could have been borrowed as decoration. Both
guards were verified by reintroducing the defect.
Still true: nothing learns, and company text does not enter
knowledge_chunks. Per-tenant content in a corpus that is public by
construction is a scoping decision, not a runner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF28ca165v262 closed the item the previous entry had listed as still open, so the entry is struck through and replaced with what was actually built rather than left for a future session to read as outstanding work. It also records the two things that made it more than a route: the script resumed a step by comparing the pathname to the route it NAVIGATED to, which after a redirect never matches, and a test asserted that something was skipped rather than that the filter works -- so closing the gap failed it. And the local measurement that will otherwise confuse the next person: 9 of 390 open tenders have readable text on a dev box against 582 of 596 in production, so the tender and document steps pick different tenders here and the same one there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
9e0b093Two of the nine steps carried `live=""`, so `live_steps()` dropped them and
the guided tour skipped the screen where the four states live and the one
where the documents are read -- its two most important. Both steps were
written, with anchors that exist on the real screens. Nothing had ever
resolved them, and two docstrings said otherwise: "the tender and document
steps carry an id that only exists once the demo has ingested something --
`demo_tour_html` resolves those at render time and drops them when it
cannot". No code anywhere looked for a tender id.
It could not have been resolved where those docstrings claimed, either:
`tour.py` renders through `views`, and `views` opens no database
connections. A route already has one, so `/vodic/{kam}` is where it happens.
⚠️ NOT UNDER `/razpis/`. `/razpis/{notice_id}` exists, so `/razpis/vodic`
would be a literal fighting a parameter for one shape -- and CLAUDE.md
records `POST /dokazila/nalozi` shipping shadowed by `/dokazila/{what}`. A
separate namespace cannot be shadowed at all, which beats being declared
first and hoping nobody reorders the file.
The tender is chosen the way a demonstration should choose: something the
reader SAVED, then something we matched to them, then any open tender so the
tour still works for an account with no profile. The document step
additionally requires a pack we have read, because a reading room with
nothing in it demonstrates the opposite of the feature, and a tiebreak
prefers a readable pack among equally good candidates so both steps land on
ONE tender wherever one exists. (Locally they still differ -- 9 of 390 open
tenders have readable text here against 582 of 596 in production -- which is
a sparse dev corpus, not a design fault.) It never 404s: an empty window
sends the reader to the feed, because a tour that dead-ends is worse than a
tour one step shorter.
⚠️ AND THE SCRIPT COMPARED THE WRONG THING. It resumed a step by testing
`location.pathname === data-tour-route`, which after a redirect never
matches -- the card would navigate correctly and then refuse to open. Steps
now declare the path they RECOGNISE as well as the one they navigate to.
Anchored patterns, not prefixes: both live under `/razpis/`, so on a prefix
test the documents step would consider itself already arrived while the
reader was still on the tender page.
⚠️ A TEST WAS PINNING THE GAP. `test_a_step_with_no_screen_is_skipped_not_
broken` asserted `len(live_steps()) < len(STEPS)` -- that SOMETHING was
skipped -- so closing the gap failed it. The property its name states is
about the FILTER, not about a step currently needing it, and is now
exercised with a synthetic step instead.
Driven end to end: all nine steps navigate and spotlight exactly one anchor,
including the two redirects. Signed out answers `/vpis?next=`, an unknown
step name answers 404. 2,323 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyFb776715v259 and v260 are committed and deliberately NOT deployed; the user is running the deploy. Production and the demo are both on v257, so the handoff carries the commands for both -- including the demo's own migration, which it needs because it has its own database, and the note to run each step detached so a dropped ssh session does not look like a failed build. Two lessons worth more than the fixes. ⚠️ EVERY HEADLESS PROBE IN THIS PROJECT PASSES `--hide-scrollbars`, which makes the scrollbar 0px wide and a scrollbar-driven layout shift exactly zero. The jitter reported today had been visible to any human and invisible to every measurement I have taken all week. Drop the flag when measuring anything about width. ⚠️ AND A DOCSTRING RECLASSIFIED A MODULE. `sheets.signed_in_modules()` decided membership by scanning source text for "app_shell", so a sentence saying "which `app_shell` puts in the topbar" made `api/tour.py` a signed-in screen and its display headline started failing a rule meant for dashboards. Fifth recorded instance of a guard matching the prose that explains the thing, and the first inside the derivation every other scan in tests/ is built on. It is an AST walk now. Also left named rather than quietly closed: the tour skips the tender page and the reading room, because both steps carry `live=""` and two docstrings claimed something resolved them at render time. Nothing did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
f71d572THREE REPORTS, ALL MEASURED. THE JITTER. "We click the AI helper and the UI does a little jitter, same as when you collapse the menu." One cause. The assistant panel and the rail drawer each set `documentElement.style.overflow='hidden'` so the page behind cannot scroll -- correct -- and on a desktop that REMOVES THE SCROLLBAR. Measured at 1280 with a real scrollbar: scrollbar 15px -> 0, clientWidth 1265 -> 1280, the card's right edge and the topbar's width both +15. The LEFT edge does not move, because the rail pins it, so it reads as a jitter rather than a slide -- which is exactly how it was described. `scrollbar-gutter:stable` on html. After: right edge 1242 -> 1242, topbar 973 -> 973. The content column is now 270..1243 on every app screen, short or long, so navigating from a short page to a long one no longer shifts either. ⚠️ NO PROBE IN THIS PROJECT COULD HAVE SEEN IT. Every headless run passes `--hide-scrollbars`, which makes the scrollbar 0px and the shift exactly zero. The flag that makes screenshots tidy makes this class of defect invisible. The guard is a SCAN -- anything that locks the document scroll needs the gutter -- and it immediately named a third locker I had not found by reading: the search palette had the same jitter on Ctrl-K. THE TAB ICON WAS A FOURTH DRAWING OF THE MARK. `theme.head()` hand-drew it as an inline `data:` URI: still the FOUR-BAR EQUALIZER, still `#4F46E5`, the Tailwind indigo this palette left on 2026-08-27 and the mark 0bf3242 replaced on 2026-09-03. Meanwhile `main.FAVICON_SVG`, correctly derived from MARK_RULES, was served at /favicon.svg and LINKED FROM NOWHERE. `TestThereIsOnlyOneDrawingOfTheMark` checked the rail and the favicon's geometry, so it read as covering the mark while missing the copy on every page in the product. The link points at the route now, versioned by a hash of the DRAWING and its colour -- not the build, which would throw a correct week-long cache away on every deploy. THIRTEEN THINGS LIFTED 2px ON HOVER -- four buttons and nine cards -- so one gesture served a control and a surface and meant nothing in either. Three gestures now, one per role: the PRIMARY rises and blooms, a GHOST fills in (the rule was already there and the blanket lift was overriding it), and a CARD does not move -- its edge lights and its glow follows the cursor, which it was already doing underneath the lift. AND TWENTY FULL-WIDTH HAIRLINES BECAME SEVEN. Removing `section.band`'s border on 2026-09-03 fixed the sections; the same disease was one level down. Seven FAQ cards drew fourteen rules, five steps drew four more, and `.states` built a hairline grid out of a 1px gap. The FAQ and the quote were ALREADY surfaces -- the border was a second, louder way of saying so, and CLAUDE.md records the dark theme being retuned because "the outlines beat the surfaces". The steps trade four horizontal rules for one VERTICAL spine through the numbers, which is a line that means sequence and cannot be mistaken for a section break. The seven that remain are the report mockup's (it depicts a document), the signup card's own edge, and the footer. 2,320 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
d874cd2`api/tour.py` has walked a reader around the REAL screens since 2026-08-31, spotlighting a `data-tour` anchor on each -- and it was gated on `demo.is_demo()` together with the demo strip, so a paying customer's first evening offered no tour while the one written for them sat behind a flag. Seven of its nine steps are ordinary signed-in routes. That is the "already built and never shown" shape `docs/13-next-session.md` warns about. Driven end to end in a browser, all seven: /profil -> /dokazila -> /e-posta -> /app -> /ujemanja -> /iskalniki -> /pod-pragom, each navigating and spotlighting exactly ONE anchor, including `pod-pragom`'s conditional one. Zapri vodic closes the card, clears the spotlight and forgets the step. Opt-in, never auto-start, by the user's decision: the script resumes a step across the navigation a step performed and opens nothing because a page loaded. The launcher is a compass in the topbar beside Pomocnik -- outlined, not filled, because `views_copilot.CSS` says `.cop-open` is the only filled control in the bar and two filled pills is a bar with no primary action. A compass because `unknown` is a question mark AND one of the four states, and reaching for it would have been the sixth borrow. What stays the demo's: the STRIP, which says "you are an invented firm", and the DOCK, a persistent floating button -- the shape `.cop-open` was moved out of. `DEMO_CSS`/`DEMO_JS`/`demo_tour_html` are `TOUR_*`/`tour_html` now: a name saying "demo" on a surface every customer sees is the same lie as a comment asserting a property the code lacks. `DEMO_BAR_CSS` split out so the strip's rules stop shipping to every customer. THREE PHONE DEFECTS, measured at 390x844 by driving the steps: Zapri vodic was 73x16 -- and on a phone it is the ONLY way out, because Escape needs a keyboard. `Nazaj`/`Naprej` carry `.btn` and get theme.BASE's 44px floor; a bare button is excluded from it on purpose. 73x44 now. The spotlight CENTRED regions taller than the screen. `feed` is the whole .trow-list at 4685px and `izjava` the declaration at 1845px -- both the right things to point at -- so the tour landed 1928px INTO the feed. It asks for `start` now, with scroll-margin-top: every step's anchor tops at y=88, under the 64px bar. The card sat 9px above the tab bar. Its comment said "53px of bar plus a gap" and set 72; the tabs went to 62px on 2026-08-31 and nobody moved it. Both numbers are parsed and compared in the test now. ⚠️ AND A DOCSTRING RECLASSIFIED A MODULE. `sheets.signed_in_modules()` decided membership by scanning source TEXT for "app_shell", so `launcher()`'s docstring -- "which `app_shell` puts in the topbar" -- made `tour` a signed-in screen and its 48px display headline started failing a rule meant for dashboards. It is an AST walk now: a sentence about a function is not a call to it. Fifth recorded instance of a guard matching the prose that explains the thing, and this one sat in the derivation every other scan is built on. 2,320 tests pass. Every guard verified by reintroducing its defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
565e0e3Redeployed from v206 (2026-08-31) to v257, the same build as production.
open_opportunities 372 -> 424 there, because the demo has its own database and
had never had a migration; the buyer-guard fix recovered 52 hidden open
tenders against production's 4.
⚠️ IT COULD NOT HAVE BEEN REDEPLOYED. `DEMO_DB_PASSWORD` and
`DEMO_SESSION_SECRET` are `${VAR:?}` in the compose file and neither was in
the box's .env -- the stack had been started once with them passed inline and
kept alive by `restart: unless-stopped`, so every compose command failed at
interpolation before doing anything.
And regenerating them was not an option: POSTGRES_PASSWORD takes effect only
on first init, so fresh values give a stack that builds and cannot open its
own database. The only copy of the working password was the DATABASE_URL
inside the running api container. Remove that container before recovering it
and the demo database is unreadable while its volume sits there intact.
Recovered and appended to /root/tenderwatch/.env, mode 600, no comments,
with a backup beside it. A `${VAR:?}` that lives nowhere is a stack you can
run and cannot operate.
Verified live: six 200s, /nadzor and /api/health 503 without STATUS_TOKEN,
all four demo.BLOCKED POSTs refused by middleware with their own explanation
rather than an auth bounce, no Anthropic key and no SMTP password in the
container, the corrected declaration question rendering and the old wording
gone, and BOTH headers measuring 0 wrongly invisible -- the first live proof
of the dashboard fix, since production cannot be checked without signing in
as a real customer.
Left open, because it is a product decision: the demo ingests weekly and the
intake pill reads the worst source, so a prospect often meets a header saying
"Zajem ne teče". The pill is being honest and must not be special-cased
quietly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyFc1d795bThe first exclusion-ground question read "Ali je bilo vaše podjetje ali kdo od vodstva pravnomočno obsojen za katero od teh kaznivih dejanj?" -- where `je bilo` agrees with *podjetje* (neuter) and `obsojen` agrees with *kdo* (masculine), in one sentence. There is no participle that agrees with both, so the sentence asks for a noun instead: "Ali IMA vaše podjetje ali kdo od vodstva pravnomočno OBSODBO ...". Checked all nine rather than only the reported one. The other eight address the reader directly -- "Ali imate ...", "Vam je bila ...", "Ste ..." -- and agree correctly; this was the single odd one out, and the new form matches their idiom as well as their grammar. Nothing pins the wording: `test_qualify` asserts only that a question differs from the statute it restates and ends in a question mark. That is the right level for it to test, and it is why this went unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF
2715a09v254 and v255 are both live; the box reports v255 (de450a7) from inside the
container and the tree is clean.
The migration went exactly as predicted, and the prediction is the part worth
keeping: the new `current_notices` was simulated against production data
BEFORE it was applied, and the old form was simulated too -- reproducing the
live 579 exactly, which is what made the new number trustworthy. 583 and
14,195 predicted, 583 and 14,195 after. Do that again for any change to the
feed's views; two queries turn "expect it to rise" into a number.
⚠️ And the headline is not the user-visible number. The commit says 254 keys
over 1,284 notices; what it bought the FEED that day was 4 open tenders,
because most of the colliding rows are closed or are awards.
CLAUDE.md gains four rules, all measured today:
A PSEUDO-ELEMENT BINDS TO THE LAST SELECTOR OF A GROUP. `A,B,C::after{...}`
gives A and B the declarations raw, which is why the signed-in topbar and the
landing page's whole navigation rendered at 0x0 on production for a day. The
same `.split(",")` tears `:is(a,button)` in half, and the test that should
have caught it was passing BECAUSE of that tearing -- third instance of a
test pinning the bug it is named after.
THE SIZE OF A GLYPH BELONGS ON THE RULE. `icon()` emits a bare `<svg
viewBox>`, and an SVG with a viewBox and no intrinsic size fills its box.
AN IN-PAGE ANCHOR MUST CLEAR THE STICKY BAR, derived as `.content [id]`
rather than named per anchor, because `#t-{id}` after every save had the
same defect as the calendar and nobody had noticed.
A DEMONSTRATION MUST NOT WIRE INVENTED DATA TO LIVE CONTROLS.
The handoff also carries the method notes, because three of them cost real
time: emulate a phone with CDP device metrics rather than `--window-size`,
`COOKIE_SECURE=false` to drive a local session, and `MSYS_NO_PATHCONV=1`
because this shell rewrites an argument starting with `/` into a Windows path
and CDP then answers "Cannot navigate to invalid URL".
And what found the defects, in order of yield: a screenshot looked at, a
crawl of every internal link the product renders, and the user twice. Reading
code found none of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyFde450a7Reported by the user: "the header inside of the dashboard is now absent".
It was, and so was the landing page's whole navigation, on production since
2026-09-03.
`TAPPABLE` was one comma-joined string of three selectors and the ripple was
written `__TAPPABLE__::after{...}`. Substituted, that is `A,B,C::after{...}`,
and a pseudo-element binds to the LAST selector only -- so A and B took the
ripple's own declarations raw: position:absolute, opacity:0, z-index:-1,
transform:scale(0). A is `.tb-inner` and B is `.site-head .inner`.
Measured on the live site before the fix: the search trigger, the intake
pill, the assistant launcher, the account chip, the theme toggle and all four
landing nav links at 0x0 and opacity 0, stacked at the exact centre of a bar
still 72px tall. Only `.btn` survived, because the selector excludes it.
The same `.split(",")` also cut inside `:is(a,button)`, so each selector was
torn before its suffix was appended. `:active` survived that by luck;
`.rippling::after` produced `:is(a.rippling::after,button)`, which is not a
valid thing to write, so the ripple animation was dead too.
⚠️ AND THE TEST WAS PASSING BECAUSE OF THE BUG. `test_the_press_is_on_the_tap
_speed` split TAPPABLE on commas and looked for `.tb-inner :is(a` + `:active`
-- a string that existed only because the same bad split had torn the
selector while BUILDING the sheet. It is the shape CLAUDE.md already records
twice: a test that pins the bug it is named after. `TAPPABLE_PARTS` is a list
now, every join is per-selector, and a new guard asks each part for each
suffix. A second states the property without naming a slot: a block that
positions something absolutely at opacity 0 behind its parent is a ripple,
and a ripple may only ever be a pseudo-element.
⚠️ My first version of that guard made the identical comma-split mistake, so
`group_parts()` splits on top-level commas only and says why.
AN ANCHOR JUMP LANDED UNDER THE STICKY BAR. Reported about `/koledar`:
clicking a day scrolls "just enough down to hide the day you clicked".
Measured: the date heading landed at y=0 while the bar occupies 0..72
(0..64 on a phone), so all 43px of it was behind the chrome. The rule is
`.content [id]`, not four named anchors -- `#t-{id}` is where every save and
dismiss redirects, so the row you just saved was hidden too, and `#vsebina`
is the skip link. All now land at y=96.
THREE MORE, FOUND BY WALKING THE PRODUCT AS THE SEEDED FIRM.
Every dismiss and restore glyph drew at 42x42 against the save button's
15x15, filling its 44px box edge to edge, on every match row at both widths.
`icon()` emits a bare `<svg viewBox>` with no intrinsic size; `save_button`
happened to pass `cls='star'` and `dismiss_button` passed nothing. The size
lives on the rule now, not at the call site.
`/predstavitev` wired its five INVENTED tenders to live controls: the card
404'd, the right-click menu offered two more 404s, and Shrani answered 500.
A signed-out visitor -- the audience for that page -- was sent to
`/vpis?next=/razpis/{invented}`, so registering landed them on a dead page as
their first act inside the product. An example row keeps every pixel and
gives up every destination.
And `POST /shrani/{well-formed uuid that is not a notice}` was a 500 for any
signed-in user. `toggle_saved` already anticipated a bad id and catches
`InvalidTextRepresentation`; that guards the SHAPE and says nothing about
existence. `saved.toggle`'s own docstring promised "a defined state, not a
crash" -- the fifth instance of a comment asserting a property the code did
not have. The insert is conditional on the notice now.
2,304 tests pass. Every guard was verified by reintroducing its defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWwKBtGCrZztzwzQv6ZuyF4a6c66cv253 is committed and deliberately not deployed: the migration drops and rebuilds `current_notices_mv`, and I told the user that is worth watching rather than doing unattended at the end of a session. The handoff now says what to check the moment it lands. `open_opportunities` was 600 before the fix and should RISE, because 254 procedure keys were showing one tender and hiding the rest. If it falls, the buyer key is splitting corrigenda -- measured at one family in 4,143, so that would mean something is wrong. Section 3's OCR item is struck through rather than left standing. Both its figures were misleading: most of the "read errors" were macOS sidecars that are now deleted, and the "584 PDFs" counted scans rather than unread scans, 565 of which had already been read. The real backlog was 40, worth one tender. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
3f6a54aA ten-agent review raised 58 findings; eight went to skeptics told to refute them and none could. All eight are fixed here. FOUR WERE A COMMENT ASSERTING A PROPERTY THE CODE DID NOT HAVE, which is worse than no comment because it stops the next reader checking. `main.py` said served files opt out of gzip -- neither FileResponse set the header, so every PDF in a 46 MB pack was compressed at level 9. `run_awards.py` said the lock is taken before the first request -- three of six runners crawled above it, spending a CPV tree, a NUTS list and a grid walk before finding out another run held the lock. And a test named `test_every_runner_takes_a_lock_before_it_crawls` asserted only that a lock exists somewhere in the file; it is renamed to what it checked, and the property it was named after is now a real AST test. THE FEED WAS HIDING OPEN TENDERS. `_FIND_BY_REFERENCE` has always refused to merge two notices unless the buyers agree, naming "1/2026" in its own comment as the collision it guards. `current_notices` had no such guard, and DISTINCT ON discards rather than merges. Measured on production: 254 procedure keys carried more than one buyer across 1,284 notices, and the key "1/2025" alone is shared by Gasilska zveza Domžale, Gasilska zveza Metlika, Občina Idrija, PGD Dobrovo and PGD Srednja vas -- one row survived, the rest were invisible while still open. The risk was the other direction and was measured rather than assumed: a popravek carries its parent's buyer, so of 4,143 corrigendum families covering 11,107 notices, exactly one separates. `buyer_identifier` is 100% populated, so the key mirrors `_FIND_BY_REFERENCE` instead of inventing a second rule. `_SIBLINGS` needed the same guard and lacked it -- it feeds `record_supersession`, which writes deadline_moved into the morning mail. AND THAT FIX WAS UNSHIPPABLE. `current_notices` sat ABOVE the migration marker, so `migrate.py` could never reach it: verified by executing `migrations()`, whose text held notice_keys, current_notices_mv and open_opportunities and zero occurrences of current_notices. It is in the tail now, and it has to be DROPPED rather than replaced -- the view is `SELECT n.*` plus computed columns, so columns added to `notices` since land before them and Postgres refused with "cannot change name of view column procedure_key to awarded_value". The cascade is bounded and measured; both dependants rebuild in the same transaction. THE PAUSE-AFTER-THE-CALL DEFECT was in five more places -- all four `evidencna` methods and `enarocanje.lookups` twice -- because the guard that proved it fixed in `questions.fetch` read one file by name. `tests/test_pacing.py` derives the module list from the package and accepts both correct shapes: a pause in a finally, or a pause before the request, which `detail()` has always done. Its own first version exempted any function containing a loop, which exempted `evidencna.lines` -- one request, then a loop over CSV rows. Caught by reintroducing the defect and watching it pass. AN EMPTY PORTAL RUN wrote no ingestion_runs row, so the zero-notices alarm could never fire for the source it was written for. `store_matches` was one round trip per match on every /app and /ujemanja view: 19 ms -> 5 ms on 36 matches, still idempotent. `select.candidates` never passed its limit to the database, and `run_extract` called it twice -- ranked once and sliced twice now. 2,288 tests pass. Every guard was verified by reintroducing its defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
d23810d`has_text_layer` is a fact about the PDF and never changes: a scan that OCR read successfully is still a scan. So the tender page's single "skenirano" label was saying *we cannot read this* about every scanned document in the corpus -- including the 565 of 624 that had already been read and whose text `/vsebina` was searching -- in a clay ACT_FIRST pill, which is the palette's "you must do something about this". It invited the reader to open the file and type out conditions we already held. Three states now, because they are three different facts: skenirano, prebrano OCR produced text; nothing is owed skenirano, še beremo nobody has tried yet; 60 clear a night skenirano, brez besedila OCR ran and found nothing; final Only the middle one keeps the amber pill. It is the only one that changes on its own, so it is the only one where "we are dealing with it" is true. "brez besedila" is a property of the document rather than something to act on, and takes the neutral pill for the reason CLAUDE.md records for a closed question window. An absent flag falls back to "še beremo", never to "prebrano": the honest direction is to promise a read we will perform, not to claim one we did not. The route selects two more booleans for it, and asks about `extracted_text` rather than selecting it -- the column runs to hundreds of KB a row and one tender in this corpus lists 90 files. ⚠️ The tests that cover the label hand it dicts built in the test file, so they pass whether or not the route fetches those keys, and the fallback is deliberately silent. That is how `documents.kind` reached production against a column that does not exist. So a second guard derives BOTH ends -- the keys from the label functions, the SELECT from the route -- and fails when a state is added that the query was not taught to fetch. Verified by deleting the two fields from the query. The reading room's own "skenirano" is left alone: there it is a claim about the FILE (images, no selectable text), which stays true whether or not we read it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
ea8b346Three commits ago I wrote, here and in the handoff, that "the honest way to raise coverage is legacy Office support, not more OCR". That counted FILES. The number that decides anything is TENDERS, and measured: 596 open tenders have a pack 582 of them already hold something readable 97.7% 116 contain a legacy .doc or .xls 112 of those already hold a readable file beside it So the whole of legacy Office support is worth FOUR open tenders and is not worth a dependency. The same arithmetic disposes of the OCR queue as a coverage argument: a scanned PDF sits next to the instructions document nearly every time. Document coverage is not the constraint on this product and has not been for a while. The constraint is that nothing reads the documents we already hold. Corrected in both places rather than added as a footnote, because CLAUDE.md is read as settled fact and a wrong entry is worse than a missing one -- which is the rule that file states about itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
9040bd6v249 is live and verified from inside the container. The cleanup removed 203 rows across 32 tenders and left `read_ok` at 6,183 either side of it, which is the property that mattered: nothing that produced text was touched. A manifest of what went is on the box. Two figures the older notes had wrong are corrected rather than repeated. Extraction is ~$306 a backfill and ~$560 a month, not ~$40 and ~$20 -- that estimate predated the all-CPV widening. And the "never read" pile is mostly formats we do not read at all, not a queue: 237 legacy .doc, 86 .xls, 72 PDFs. The OCR item is smaller than it reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
5c6f62e182 documents had recorded a read error. 158 of them turned out to be AppleDouble sidecars: the `__MACOSX/._<name>` files a Mac puts in a zip, a few hundred bytes of author name and timestamps. They reached the reader because two correct decisions compose badly. `safe_extract` flattens members, and `safe_name` strips a leading `._` because a leading dot makes a hidden file. So `__MACOSX/._Razpisna dokumentacija.pdf` came out as `Razpisna dokumentacija.pdf`, collided with the document it shadows, and was written beside it as `Razpisna dokumentacija_2.pdf`. Measured on production: 203 stored documents across 32 tenders are this, 185 sidecars plus 18 DS_Store / Thumbs.db / desktop.ini. One open tender holds 90 files of which 43 are sidecars -- half its reading room opening to nothing, on the screen a contractor uses to find the conditions. THE ERROR COUNT UNDERSTATED IT. 158 sidecars had a read_error and 27 did not, because a sidecar for a .jpg or a .dwg is never sent to a reader and so never fails. Counting failures found 78% of the problem and presented it as the whole of it. THE NAME CANNOT IDENTIFY THEM. After `safe_name` the filename is byte-for-byte the real document's, and `_2` is an ordinary suffix here: 438 suffixed documents read perfectly well, because packs really do repeat filenames across folders. So the two halves use different evidence. The fetcher skips by member PATH, before naming, while the path still knows where it came from. The cleanup identifies by CONTENT, on the AppleDouble magic 0x00051607, which nothing a buyer publishes starts with. `scripts/drop_pack_junk.py` is dry-run by default, never removes a row that produced text -- if a file both carries that magic and yields text then the assumption is wrong and the row stays -- and refuses to run against an image whose unpacker would refill the table on the next pack. The skip sits ABOVE the size and compression-ratio guards, so litter can never be the reason a real pack is rejected: 20 MB of zeroes in a sidecar would otherwise trip the zip-bomb check and lose the pack it came in. Also recorded, unfixed: the "never read" pile is mostly not a backlog. 237 are legacy .doc and 86 .xls, which `extract/text.py` does not read at all; 79 are images, 48 nested archives, 20 DWG. Only 72 are PDFs. The honest way to raise coverage there is legacy Office support, not OCR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
3a1bedbMeasured on production before touching anything: no `extract_requirements` run has ever happened, and 0 of 570 open tenders carry a document-derived requirement. The signed-in product has been honest about that the whole time -- every tender page says "Pogoji še niso izluščeni". The landing page said the opposite, in the present tense, in five places: the hero, the meta description, a feature card, and two FAQ answers promising "pogoj za pogojem, s citatom". And `/registracija` answered 200 with a real password field beside it. So a stranger could sign up today and receive a product missing the thing the page is built around. That is the same overclaim the four states exist to refuse, pointed outward at a customer instead of inward at a tender. WHAT THE PAGE SAYS NOW. Every pipeline step carries LIVE / PARTIAL / SOON as a field rather than as a word someone remembers to write, and it is rendered beside the step. Zajem, Ujemanje and Dokumentacija run. Razčlenitev does not. Odgovor is PARTIAL, because `qualify.py` answers the ZJN-3 75. člen grounds from a rule today and calling that either "deluje" or "v pripravi" would throw away one half or claim the other -- which is the move the four states themselves refuse. The marker is ink and weight, never a state colour. The palette means something about a tender; this means something about us, and CLAUDE.md records five previous borrows that all had to be reverted. The hero, the meta description, the feature card and three FAQ answers were rewritten to what runs. One of them was stale in the OTHER direction: "Ujemanje in razčlenitev sta v izdelavi" months after matching started working. An honesty statement nobody re-measures decays like any other claim. REGISTRATION IS SHUT BY DEFAULT. `REGISTRATION_OPEN` opens it; anything that is not an explicit yes reads as no. A forgotten line costs a signup we did not want yet, where the opposite default costs a first impression -- the same reasoning as STATUS_TOKEN serving 503 when unset. The gate is on the POST, not only in the markup: a hidden button is not a closed door when the form action is public and the route is in every smoke sweep. The shut page answers 200 with the waitlist rather than a 404, and names the reason, because somebody who typed that address is owed both. The sign-in page and the header button follow the same flag, so neither offers a door that is shut. `tests/conftest.py` opens it for the suite in one place, and says why: nearly every integration test creates its subject through that route, so leaving it shut would not test a closed product, it would delete the suite. `tests/test_registration_gate.py` closes it again, proves the POST creates nothing, proves the flag still opens the real route, and ties the two surfaces together -- the landing page may not claim extraction runs while `views_tender` is still telling readers that it does not. Also recorded, unfixed: `—` is a hole in the long-dash guard, which walks the AST for literal U+2014 and cannot see the entity that renders as the same glyph. Five were nearly added here and were rewritten; three already stand in `views_ask.py` and `views_copilot.py`, and nobody has decided whether the entity is an accepted idiom or three unnoticed instances of the same hole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
b8a727aTwo things, and the second is why the first exists.
THE BRIEF WAS WRONG ABOUT THE PRICE BY ABOUT TWENTY-FIVE TIMES. CLAUDE.md
has said "~$40 for one pass over every currently-open tender, ~$20/month
ongoing" since 2026-08-25 -- written the day before the ingest stopped
being construction-only. Division 45 is 11% of Slovenian notices, so
widening the scope multiplied the corpus about ninefold and nobody
revisited the arithmetic.
Corrected against production, measured through the selector itself rather
than estimated: 528 open tenders offer a conditions document, which at the
default two files each is ~1,056 documents and about $306. Ongoing is
about $560 a month, not $20 -- 64 notices a day arrive with a readable
conditions document, over a 14-day window. The CODE had this right all
along (`select.candidates` says "$136 and about $255" in its own
docstring); it was the brief that was stale, which is the more dangerous
half, because a session reads that file as settled fact and does not
re-measure.
SO: `--matched-only`. A bid, a saved tender, or an undismissed match --
84 tenders rather than 528, about $49 rather than $306, and thereafter
cost follows how many firms subscribe rather than how much Slovenia
publishes.
Four decisions inside it:
* OFF BY DEFAULT, and that direction is the safety property. A narrowing
that turned itself on would quietly stop reading tenders nobody has
matched yet -- on a fresh corpus, or the morning after a firm widens
its CPV codes, that is nearly all of them.
* The three signals are OR'd, NOT layered. A reader who saved a tender
and then dismissed the match has still asked for it; letting the
dismissal win there would drop the stronger signal on the strength of
the weaker one. Proved against real rows, not read off the SQL.
* NOT scoped per company. What a buyer requires is a fact about the
document and is the same whoever reads it -- `requirements` has no
`company_id` for that reason -- so two firms matching one tender pay
for it once.
* `--stats` prints BOTH totals and both prices, because a report that
shows one option presents it as the only one.
AND THE INDEXES, WITH AN HONEST MEASUREMENT IN BOTH DIRECTIONS. Every
existing index on `matches`, `saved_notices` and `bids` leads with
`company_id`, since every existing reader is a screen showing one firm its
own rows; this clause asks the opposite question and a composite index
cannot serve its second column.
At today's size they do NOTHING -- 53 ms against 48 ms, and the planner
never touches them. That is written into the schema comment, because an
index justified by a plausible sentence is how `idx_ev_description` came
to exist while the query beside it could not use it.
What justifies them is the cliff, and that was measured too: grown to
216,135 matches in a rolled-back transaction, which is roughly a
fifty-firm shape, the scoped query goes 828 ms -> 56 ms and the planner
does choose the index. Production's plan already shows the shape it
degrades through: `Seq Scan on saved_notices ... loops=2699`, one per
candidate document.
The table list in the test is derived from the clause, so a fourth signal
fails the suite until it is given an index.
Also recorded: extraction is not merely "off". `ingestion_runs` has no
`extract_requirements` row at all, and of 570 open tenders not one carries
a document-derived requirement -- the 121 rows are 90 statutory grounds
from a rule plus 31 spike leftovers on closed notices. And the hold is now
a decision rather than a gap: no key is being issued for now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB78e50adv245 is on the box and verified from inside the container, so the previous session's nine fixes are live for the first time. The six found today all share a shape worth naming at the top of the file: not one of them could have reported itself, and the suite was green through all of them. The demo's upload route is written up as a decision rather than taken. It is anonymous by design -- the demo mints a session for every visitor -- and unthrottled and unquota'd by omission, on a disk four other stacks share. Blocking it costs the demonstration of the feature that shipped this week; a quota touches production. Either is the user's call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
218d765Six failures, none of which could have reported itself. Found by continuing the previous session's handoff rather than by a test: the suite was green throughout, as it was for that session's nine. THE DEPLOY BLOCKER. `api/vault.py` shipped writing customer certificates to COMPANY_STORAGE_PATH and neither compose file mounted anything there, nor did .env.example name the variable. Nothing fails: the upload succeeds into the container's writable layer, the row is written, the page renders the file back, and the next `up -d` deletes it while `company_documents` goes on listing it. The `company` volume is read-write on `api` (the only service that receives an upload) and on `worker` (the only service a one-off job may be run on); the packs stay `:ro`. `demo_company` likewise, on `api` and on `reset`. tests/test_storage_volumes.py derives both ends -- the roots from every *_STORAGE_PATH the source reads, the mounts from the compose files. Its first version asked only whether the path appeared somewhere in the file and PASSED with `api`'s mount deleted, because `worker` also holds one; that configuration is the original defect exactly. The receiving service is derived from the command now. THE CALENDAR LED NOWHERE. Both .ics routes read PUBLIC_URL, which nothing sets anywhere, so it was always "" and ics.py omits the URL line when the url is empty -- every calendar file exported since the feature shipped carried a deadline and no way back to the tender. Measured on the seeded firm: 0 of 65 events had a link, now 65 of 65. `mailer.site_url()` was already the one definition, with a comment in views_version saying to use it. SEED_DEMO COULD NOT RUN IN A CHECKOUT. It never called load_dotenv(), alone among the runnable modules that open a connection, so it died on "DATABASE_URL is not set" anywhere except the container -- which is the one environment that cannot expose the omission. The handoff's own closing advice was to run it plus a browser before writing anything. AND ITS WIPE ORPHANED THE BYTES. Plain SQL on the evidence rows; company_documents cascades, nothing cascades a file. On the demo that is hourly. It deletes through vault now, scoped by company rather than walked per owner kind. THE UPLOAD NEVER CLOSED ITS FORM. Starlette spools multipart to real files past 1 MB; `await request.form()` leaves the descriptor and the temp file to the garbage collector, on the one route accepting 25 MB from any signed-in user. `async with` now. The guard for it had been passing on PROSE, in three spellings: raw text matched the comment explaining the fix, tokenize-stripped text matched a docstring forty lines up, and only an AST walk reads what runs. Verified by reintroducing the bare await with all the prose left in place as bait: only the closing guard fires, and its sibling correctly still passes. FIVE OF SEVEN PARAMETERISED ROUTES WERE NEVER REQUESTED. test_smoke_ routes derives screens from the router and drops every path with a brace; a hand-picked pair covered the rest. The five uncovered were the firm's own files, both new .ics routes, the ask page and the saved search -- the newest code in the product. Each route now declares how to OBTAIN a real parameter, the two scoped to the firm by creating the thing through the product's own form, and a route with no resolver fails rather than skips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSX2buViTYhK8ym9vsn1DB
e625d56Ticks the three loose ends this sitting closed and records the shape of what happened rather than a list of commits: nine defects, not one of them caught by a test. Six were found by rendering a page and looking, two by reading the browser console, one by an existing guard the moment a route was added -- with ~2,000 tests green throughout. So the recommendation is a method rather than a task: seed the demo firm and open a browser before writing anything. ⚠️ And one thing that must happen BEFORE the next deploy: `COMPANY_STORAGE_PATH` has no volume in `docker-compose.yml`, so the evidence upload would write inside the container and lose every file on the next `up -d`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mcu1CdEkkk8BUUHnFyKH9p
ac75040The three loose ends from the last summary.
⚠️ NOTHING IN THE PRODUCT EMITTED `text/calendar`. `/koledar` gathers three
kinds of date that used to live on three screens, and it is still a page
somebody has to remember to open -- so the dates that decide an evening were
in our calendar and not in theirs. `/koledar.ics` and
`/razpis/{id}/rok.ics` now, linked from both screens.
A DOWNLOAD, not a subscription. A `webcal:` feed the reader's calendar polls
would be better and needs an unguessable per-user token in a URL that then
lives in their calendar client for ever, syncing to whatever server they use.
That is a decision about a credential, not a formatting detail.
⚠️ RFC 5545, NOT "NEARLY". Calendar clients are strict in exactly the places
that are invisible in a text editor, and the failure mode is that the file
works for whoever tested it:
- CRLF between every line, and a trailing one.
- Folding at 75 OCTETS, never characters and never mid-character. A
Slovenian title is full of two-byte letters, so a 74-character line can
be 90 octets -- and cutting a sequence in half puts a replacement
character in somebody's calendar.
- A timed event carries DURATION, because zero length renders as a hairline
some clients do not draw; an all-day event carries an EXCLUSIVE DTEND.
- A stable, namespaced UID, or a reader who re-downloads after a corrigendum
gets two deadlines for one tender.
⚠️ AND A DEADLINE HAS AN HOUR WHILE AN EXPIRY DOES NOT. 12:00 and 23:59 on
one day are different working days; a certificate is valid THROUGH its last
day, and putting it at midnight says it lapsed the evening before. `Entry`
carries the moment now, parsed by `enarocanje.parse_dt` and never a second
parser.
⚠️ The export inherits the screen's scope -- `CLAUDE.md` records the same
defect on /ujemanja, where the Excel button downloaded the whole national
feed because the scope lived in the PATH.
Three things the real output showed after the code was written: a question
window that shut last week still raised an alarm, the calendar name was cut
mid-word ("na mostu Ručn"), and every diacritic in the filename became a dash
("Odprava--kode-po-neurju"). All three fixed; the transliteration covers
Slovenian completely.
⚠️ AND TWO SELECTORS MEANT TWO THINGS. `.lead-*` was defined in `landing` and
`views_leads` -- a LEAD is a buyer worth approaching on /pod-pragom and a form
that captures one on the landing page -- and `.chg` in `views_changes` and
`views_version`, where a CHANGE is an amendment to a tender on one screen and
a release of this software on the other. Nothing was broken, because the
screens never coexist. That is why it needed a test rather than a bug report:
the day two of them do, the cascade decides which wins and nobody knows.
`TestOneDefinitionPerSelector` ran over nine chosen sheets and runs over all
thirty-three now, deduplicated by identity so an alias is not reported as
clashing with itself, with keyframe stops excluded because `selectors()` reads
`from`, `to` and `50%` as rules. Verified by putting `.chg` back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mcu1CdEkkk8BUUHnFyKH9p4fa4c48CLAUDE.md gains the evidence vault and the bid state as decisions rather than
as history, plus the six defects from this session that are worth carrying as
RULES rather than as an account of a day:
- `one()` takes a parsed form, and three call sites gave it a multidict
- a shadowed `cls` cost half the tender pages their deadline colour
- a literal backspace where `\b` was meant, in two guards that had
therefore matched nothing since they were written
- a regex literal across a line break killed `MENU_JS` on every screen
- `frame-ancestors 'none'` broke both places the product frames itself
- `evidence._list` stored a repr, and the ladder's top rung could never
fire for a seeded firm
Every one of them was found by using the product or reading a browser
console, and none of them by a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mcu1CdEkkk8BUUHnFyKH9pKaj pomeni oznaka. Številka za „v“ je zaporedna
številka objave: v42 je dvainštirideseta objava v zgodovini te
kode, zato je vedno novejša od v41. Pod njo je oznaka objave
(commit): natančen zapis, iz katerega je bil narejen paket. Zapiše ga Git
sam ob pakiranju, zato se ne more razlikovati od kode, ki je prispela.
Zaporedna številka je izpeljana iz njega in ne obratno; kadar je ni mogoče
ugotoviti, tu piše oznaka objave in ne izmišljena številka.
Datum gradnje in „teče od“ sta namenoma dve številki: če se razlikujeta za več dni, je bila koda prenesena, strežnik pa ni bil ponovno zagnan.
Na telefon
Skenirajte kodo s telefonom in dodajte TenderWatch na domači zaslon. Odpre se brez naslovne vrstice, kot aplikacija.
Na telefonu kodo preskočite: v meniju brskalnika izberite »Dodaj na domači zaslon«.
https://demo.tenderwatch.lixum.agency/app