Changelog

What's new

Recent improvements, features, and fixes shipped across Skinlyzer, Formulation, and Manufacturing.

85 ticketsFeatures: 11Enhancements: 24Bug fixes: 42Under the hood: 6May 6, 2026 – Nov 2, 2026

Features11

FEATURE

Allergen import: aggregate isomers into the declarable allergen per Annex III

Closed: the feature is complete and live (formulator-ai main b6a1043, rubedyne master e3729c30, both verified on 2026-08-31). The last open item, the re-import that repairs material 13762, is also done. Checked on 2026-10-22, "Borsmenta illóolaj Silvestris" now holds Menthol 43.9 % and Limonene 4.6 %, as the certificate states. Side note, not blocking: that material's allergen list still has a few separate zero-value isomer rows (Neral/Geranial, D-/L-carvone, Alpha-/Beta-pinenes), probably left over from an earlier import.

allergenicsformulator-aiimportrubedyne-web
FEATURE

BPR: IPC measurement stage (In production / Before release) and IPC release gate

Each IPC check can now be measured "In production" or "Before release". IPC results can be recorded by production or QA until the release decision. Complete waits only for in-production checks. A batch can be released only when every IPC result is recorded and passed: any IPC Fail leaves Reject as the only option. Changing a recorded value needs a reason and is logged. Live since 2026-10-15 (API 6a0617e2, web cce4f0ac).

bprflowlyzeripc
FEATURE

Batches: track in-progress batches by status + harden the BPR lifecycle (Start gate, Cancel, signatures)

The Batches list now tracks every batch, not only finished ones. Each row shows its current status (Draft, Issued, In production, Completed, QA review, Released, Rejected, Cancelled) and the next step. Status tabs with counts let you filter the list. The batch lifecycle is stricter: - A batch needs its batch number and planned fill before production can start. - Completing a batch and submitting it for QA are both signed. - An issued batch can be cancelled with a reason instead of being deleted. - Correcting a batch number or date while the batch is in production requires a reason, and the change is logged. - Batch numbers are unique per manufacturer. - A project that has batches can no longer be deleted. Live since 2026-10-15: database migration applied, API cosahh-rest master fb8b0b5d, web rubedyne master 258cbdc2.

bprgmpmanufacturing
FEATURE

Alert when a paid checkout is left PENDING (token packs, subscription initial)

Live since 2026-10-14 (cosahh-rest master ac378ad7, devel 6b666738). A new hourly job (`StuckPaymentAlertJob`, runs at :15) finds checkouts that Stripe has settled but whose Payment row is still PENDING. - **What it checks:** PENDING TOKEN_PACK_20, EXTRA_PIF and SUBSCRIPTION_INITIAL rows that are between 1 hour and 7 days old. Each one is read back from Stripe as a Checkout Session. - **When it alerts:** the session is complete and `payment_status` is not `unpaid`. It then logs at ERROR and emails feedback.admin.email, at most once per payment per 24 h. Abandoned checkouts (expired or unpaid) are ignored. - **What it does not do:** it only alerts and never activates anything. - **Failure handling:** if any Stripe lookup fails (for example a revoked key), the run logs an ERROR, so the alarm cannot go blind silently. Configuration: prod cosahh-rest `.env` has `STRIPE_SECRET_KEY` set to the web's restricted test key, which belongs to the same account as prod checkouts. This was verified against real prod sessions: abandoned ones read back as expired/unpaid, paid ones as complete/paid. At go-live this key must be swapped for a read-only restricted live key, together with the web's keys. Tests: StripeCheckoutClientTest (6) and StuckPaymentAlertJobTest (15). The email template was added to RubedyneEmailThemeTest. Mutation testing: 17 of 17 mutants killed.

billing
FEATURE

Tutorial videos inside Rubedyne: player modal, Watch tutorial buttons, welcome steps, Adeptus cards, /tutorials page

Deployed 2026-10-03: tutorial videos inside Rubedyne — player modal, Watch tutorial on 20 pages, welcome steps for new accounts, Getting-started card, Adeptus card video buttons and the /tutorials page. Verified in production with the demo account.

onboardingtutorialsui
FEATURE

Per-user Rubedyne preferences: GET/PUT /user/preferences (onboarding state)

Deployed 2026-10-03: per-user preferences endpoint (GET/PUT /user/preferences) storing the tutorial onboarding state; table created on prod, verified live.

backendtutorials
FEATURE

Tutorial library web app at tutorials.rubedyne.com with catalogue, caption search and player (cosahh-rest /tutorials API)

The Rubedyne tutorial library is live at https://tutorials.rubedyne.com. Sign in with your Rubedyne account to browse the onboarding videos by chapter and watch them with English captions. You can also search: the search covers what is said in the videos, and a result starts the video at the moment your term is mentioned.

apionboardingtutorialsweb
FEATURE

Tutorial clips that write: add/edit materials, AI manufacturing instructions, PIF creation (working copy, fast-forward, token gate)

The tutorial series now includes clips that change data: adding and editing materials, generating manufacturing instructions with AI, and creating a PIF. They run on a dedicated demo copy of the sunscreen formulation, so the reference formulation and its PIF are never modified. Long AI waits are shown sped up, and token-consuming recordings need explicit permission.

onboardingtutorialsvideo
FEATURE

Automated onboarding video pipeline (rubedyne-tutorials): 6 narrated clips recorded from production

Onboarding videos are now produced automatically from code. A pipeline records the live Rubedyne app with a demo account, adds an AI voice-over and renders branded videos with captions. Six short clips are available, covering formulations, materials, manufacturing instructions and the PIF. The whole series can be rebuilt with one command whenever the interface changes. The "Create a PIF" clip will be refreshed after 5 October with the updated "formulation" wording.

onboardingtutorialsvideo
FEATURE

PIF: COMP marker for ingredients whose MoS is assessed via their components

Some ingredients cannot be given a Margin of Safety of their own — their assessment lives somewhere else. The PIF already handled one such case: a plant extract whose safety follows from its solvent, marked "sol" in the ingredient catalogue. Essential oils need the same treatment for a different reason: their safety follows from their individual constituents, not from the whole oil. Until now they landed in the "no safety data" bucket and were flagged for review even though the assessment existed. The new "comp" marker closes that gap. It behaves exactly like the solvent marker — no MoS computed, no concentration ceiling, verdict Biztonságos — and differs only in what it says. The PIF PDF now carries that explanation in two places: a line beside the ingredient's component table in §2.7, and a footnote on its row in the §2.8 MoS table. Both texts are seeded in Hungarian and English and can be reworded at /pif/templates without a code change. Shipped in cosahh-rest (devel 9e22c81, master d4e3c8f) and rubedyne (devel f8da17a8, master 4751c1e3), covered by new tests, both suites green. No database change. Awaiting deploy — backend first, then web. One ingredient carries the marker today, Boswellia Carterii Gum Oil in PIF 16; after deploy that dossier needs a Recalculate for its verdict to update.

cosahh-restpdf-exportpifsccsundeployed
FEATURE

Fold aggregated fragrance allergens into the PIF Margin of Safety calculation

Fragrance allergens declared on a supplier's sheet now receive a full SED/MoS safety assessment in the SCCS Calculations table and the TID PDF §2.8 table. Previously such allergens got no assessment at all, and allergens appearing in both the formula breakdown and a declaration sheet had their concentration understated — which overstated the Margin of Safety. Shipped to production: cosahh-rest 7b0c2ae, rubedyne 471c6d23. Each existing PIF needs one "Reload formula" to pick it up.

allergenspifregulatorysafety-assessment

Enhancements24

ENHANCEMENT

Manufacturing Process: "Translate to" offers all 24 EU languages

The formula page's Manufacturing Process can now be translated into all 24 official EU languages (previously 7), listed in the same order as the PIF languages. Translations keep every phase letter (A–E) in Latin capitals, also in Greek and Bulgarian, and keep every number and heading of the original. Material names no longer carry a Hungarian ending in other languages. Text already in the chosen language — now also Czech, Slovak and Polish — is recognised and not charged again. Checked with the production model in all 23 target languages from both a Hungarian and an English source. Merged for the test system (devel); production rollout follows (AI service first, then the web app).

formulai18nmanufacturing-processtranslation
ENHANCEMENT

PDFs print numbers with the decimal mark of their language (PIF, CPNP, lab report)

PIF, CPNP and lab report PDFs now print numbers with the decimal mark of the document's language: a comma in 21 of the 24 EU languages (e.g. Hungarian "0,40 %", "60,0 kg"), a point in English, Maltese and Irish. Computed values (concentrations, SED/MoS/NOAEL, exposure, SPF, pH, density) and short data values (volumes, property values, Annex limits) follow the language; section numbers, references, dates, version numbers and free text stay as written. The Bulgarian template texts were corrected as well. Quality certificates already used the language's decimal mark. Live on the test system (devel); production rollout is API-only.

i18nlab-reportpdfpif
ENHANCEMENT

Formula name check: compare the product part, ignore package size, warn when a size is in the name

The similar-name check when creating, copying or renaming a formulation now looks at the product part of the name. The brand name at the start and words shared at the end no longer make two different products look alike: "Dr. Márta Forrai AHA+BHA szérum fermentált hatóanyaggal" is not flagged against "Dr. Márta Forrai Thermal szemkrém fermentált hatóanyaggal". The package size is ignored too, so "… 30 ml" and "… 50 ml" count as the same formulation. If a name contains a package size (for example "50 ml" or "100g-os"), a separate note asks you to leave it out, because the formulation is the same whatever package it is filled into. This note does not stop you from saving.

formula
ENHANCEMENT

Ingredient photo OCR: replace ocr.space with a vision model on OpenRouter's EU endpoint

Reading ingredient lists from photos is now much more accurate. Instead of a generic OCR service, the photo is read by an AI vision model through an EU-based endpoint. On our test set of real packaging photos it got 99% of the ingredients exactly right, up from 58%, including small print, curved bottles and rotated photos. A backup model takes over if the first one is unavailable. Photos taken with a phone are now resized in your browser before upload, so large photos no longer fail. The Privacy Policy (version 1.4) was updated to reflect the change of provider.

ingredientsocrprivacy
ENHANCEMENT

New batch: auto-generated Batch ID, required Formulation/Master/Manufacturer, and lifecycle deadlines for deferrable fields

**New batch** - Formulation, Master (approved) and Manufacturer are now required. A batch can no longer be created without a master. - The Batch ID is generated by the system: 10 characters from uppercase letters and digits, leaving out the look-alikes 0/O and 1/I/L. It is unique and never changes, and there is no Batch ID input any more. - Batch number, manufacturing date, batch mass and volumes can be filled in later. **Last point each field can be set** - **Batch mass:** until the batch is issued. Issue waits for it, and afterwards the mass is fixed. - **Manufacturing date:** if it is empty, Start production sets it to that day. - **Batch number, manufacturing date, volumes:** until Complete. Complete waits for them and names what is missing. - **From Completed on**, the batch details are locked. **Batch worksheet** - A new "Batch details" panel has an "Edit details" button, so missing fields can be filled in right there. **Fix** - The Batches list search no longer fails on a batch without a number, and it now also finds batches by Batch ID. Deployed to production on 2026-10-13: API master a67affd3, web master 7b55f59e, schema change applied.

bprgmp
ENHANCEMENT

BPR: block Complete until every in-process control result is recorded

Live since 2026-10-13. A batch can no longer be completed while any in-process control is missing a recorded value: "Complete batch" is disabled with a "(N missing)" hint, empty rows are marked Required, and the API refuses the completion too. API master 779dd5f8, web master 3a8a350e.

flowlyzergmp
ENHANCEMENT

Material actuals "By": leave it empty until Complete, then sign every line with the completing user

The material table's "By" column is now signed when the batch is completed. While a batch is in production, By stays empty; saving a row stores quantity and lot only (who saved it is kept in the activity log). On "Complete batch", any unsaved lines are recorded with the values shown on screen (or the planned quantity and FEFO lot), then every line is signed with the completing user's name and a single timestamp. If a line has no quantity, Complete is refused and names the material. Deployed to production (API master a262785c, web master 16a8c5d6).

bprgmp
ENHANCEMENT

BPR worksheet: pre-fill Actual with Planned and pre-select the FEFO raw-material batch while IN_PRODUCTION

## Change On an IN_PRODUCTION batch, every **unrecorded** material line (`actualQuantity == null`) now opens pre-filled: - **Actual = Planned.** The planned quantity goes into the Actual input with float noise dropped (12.300000000000001 → 12.3) and no real precision lost. - **Lot = FEFO batch.** When the line has pickable raw-material batches and no linked batch or typed lot yet, the picker pre-selects the earliest-expiring batch that is still valid; a batch expiring today counts as valid. An undated batch is chosen only when no dated batch is valid. An expired batch is **never** pre-selected; with expired batches only, the picker stays on "enter manually". The list order from the server is not relied on. - **Suggestions only.** Nothing is written until the operator presses the line's Save, which records the operator and the time. Unrecorded lines show an amber "Not recorded" marker in the By column. The operator's own choice always overrides the suggestion. Recorded lines keep their own value and lot. The suggestion is derived during render, not set from an effect. The existing guard against nulling a linked batch while its batches are still loading is unchanged. ## Why FEFO and not FIFO Raw-material batches carry no stock quantity, so the system cannot tell when a batch is used up. Expiry is the safer rule, and it is what the user chose. ## Limits - Prod has **0** `MaterialBatch` rows today, so the lot suggestion takes effect once batches are recorded for the manufacturer's materials. - BPR 6 has no manufacturer, so its lines never resolve to batches. The Actual pre-fill still works there. ## Tests - 6 lib tests for `suggestedActual` and `fefoBatch`. - 7 page tests in `bpr-material-prefill.test.tsx`. - The new behaviours were red before the change. - Full suite: 542 files / 4328 tests green, no flakes. ESLint reports 0 problems, tsc is unchanged and `npm run build` passes, on devel and master. ## Deploy - devel `68c98478`, master `2240f389` (cherry-pick). - Vercel production READY for both rubedyne and skinlyzer. Frontend only.

flowlyzergmpux
ENHANCEMENT

BPR worksheet: preview the Material actuals rows while the batch is still DRAFT

## Change While a batch is **DRAFT** and has no material lines, the BPR worksheet's Material actuals table shows a **read-only preview** built from the Master BOM. - There is one row per BOM item, in BOM order, showing the planned quantity scaled to the batch mass (`batchMass × plannedConcentration ÷ 100`, the same rule as `BprLifecycleService.issue`) and the BOM % under it. - The rows are muted, have no inputs, and sit under the caption "Preview — these rows are created when the batch is issued, scaled to the batch mass." - When a DRAFT batch has no Master or BOM available, the table is replaced by "Material lines are created when the batch is issued, scaled to the batch mass." - A non-DRAFT batch with no lines (e.g. the legacy pre-GMP BPR 2) shows "No material lines recorded for this batch." instead of a bare header. - This is frontend only. The new `previewMaterialLines()` helper is in `lib/flowlyzer/materialActuals.ts`; there are no backend or data changes. ## Tests - 3 lib tests: scaling, unknown mass or concentration, no master or BOM. - 4 page tests: preview rows with quantities, no inputs on preview rows, the DRAFT empty state, and the non-DRAFT empty state. - All 7 were red before the change. - Full suite: 541 files / 4310 tests green, no flakes. ESLint reports 0 problems, tsc is unchanged and `npm run build` passes, on both devel and master. ## Deploy - devel `c45e6624`, master `52f529f3` (cherry-pick). - Vercel production READY for both rubedyne and skinlyzer. - For BPR 9 (30 kg, master 6), the page should now show 14 preview rows, e.g. SpecKare PDRN L3 10 % → 3.00 kg and Pure water 39.835 % → 11.95 kg.

flowlyzergmpux
ENHANCEMENT

GMP masters & batch records: optional batch mass, master name/created date, explained material actuals table

Shipped to production on 2026-10-12. - **Promote to Master:** the standard batch mass is now optional. Enter it as the typical or planned batch size, or leave it blank. - **Master list:** masters created before names existed now show a readable fallback name ("Master Manufacturing Record vN") instead of a dash. - **Master detail:** the page shows when the master was created. - **Batch worksheet, Material actuals table:** - The headers carry the batch unit, e.g. "Planned (kg)". - Each column has an ⓘ tooltip that explains it. - Quantities are rounded, and the variance is signed (+ means more than planned). - The BOM concentration (%) appears under the planned mass. - The time each line was recorded appears under the operator's name. - **Batches without a batch mass:** these now show the planned quantity and variance as unknown ("—") instead of a misleading 0.

flowlyzergmp
ENHANCEMENT

formulator-ai: TypeSafe Jev integration (chunk re-ranking, playbook choice, INCI gate, checklist critique)

The formulation assistant now uses TypeSafe Jev for several AI judgments: ranking knowledge-base passages for chat answers (clearly better retrieval in both English and Hungarian, with no added latency), choosing the right formulation playbook, skipping ingredient-list extraction when none is present, and double-checking generated formulas against the checklist. Confidential formula data is never sent to Jev, and every Jev call falls back to the local model if it fails. Live since 2026-10-05.

aiformulator-aijev
ENHANCEMENT

Tutorial library: 12 follow-up fixes from the final code review

A round of small reliability and polish fixes for the tutorial library: - clearer messages for very long searches; - the page no longer hangs when the server is slow; - the video reliably starts at the right moment; - logging out returns you to the page you came from; - faster catalogue loading; - correct search highlighting for accented letters; - more robust video publishing.

apitutorialsweb
ENHANCEMENT

skinlyzer: app-help docs for the remaining 66 pages (corpus complete, 124 hand-written)

Every page in the application now has a written help description, not just a one-line placeholder — 124 in total. Each was written from that page's own behaviour, so the assistant can now explain things that were not documented anywhere before: why a raw material cannot be deleted, why saving a material sends its approved playbook back to draft, the difference between a master production record and a single batch, where a deleted product can be restored, and the file rules for a safety assessor's certificate. Checked live on the production assistant in both Hungarian and English: six such questions were answered from the new content rather than guessed. Live since 2026-09-24.

app-helpcontentformulator-ai
ENHANCEMENT

formulator-ai: leave-in hair playbooks (hair oil, leave-in conditioner)

Hair products that stay in the hair now have formulation guidance of their own: one for hair oils and serums (beard oils included) and one for leave-in sprays and creams. Until now the only hair guidance in the system was for products that get rinsed out, so a hair oil was treated as a body oil and a leave-in spray as a rinse-out conditioner. Both new guides carry the concentration limits that apply to products left on the hair, which are stricter than the rinse-off ones. Live since 2026-09-22. Still to do: the search index has not been refreshed with the two new guides, which only affects how they are retrieved in conversation, not the guidance itself.

formulator-aijevplaybooks
ENHANCEMENT

formulator-ai: the copilot picks its playbook from the formula with Jev too

When helping with a formula that is already open, the assistant now works out the product family from the formula itself — its name, category, labels and ingredients — instead of only matching words in its name. A water-in-oil hand cream saved as "Kézkrém 07" is recognised as such, and a formula with a name that says nothing now gets guidance at all. Where the match is not clear enough the previous behaviour applies unchanged, so this never makes the guidance worse. Live since 2026-09-22 (main ce4a5a4).

copilotformulator-aijev
ENHANCEMENT

formulator-ai: the critique reviewer read the fill-to-100 base as a violation

The automatic reviewer of generated formulas mistook the house notation for water — listed at 100, meaning "fill up to 100%" — for an error, in 4 of 6 test drafts, and that false finding was then sent back as something to correct. Those lines are now removed before the correction step. The concentration total is still checked exactly, by the deterministic validator, so a genuine problem is never lost. Live since 2026-09-22 (main 38e32ac).

critiqueformulator-ai
ENHANCEMENT

formulator-ai: Jev checklist Nouls run next to the generate critique reviewer

The draft review that runs before a generated formula is returned now gets a second, independent opinion that judges each checklist point on its own, in parallel with the existing reviewer, and only its confident failures are added. Measurement decided the design: on its own it was weaker than the current reviewer, especially on anything requiring calculation or a regulatory limit, but at high confidence it was right every time and caught faults the reviewer missed, such as a preservative left in the hot phase. No added waiting time. Live since 2026-09-22 (main 38e32ac).

critiqueformulator-aijev
ENHANCEMENT

formulator-ai: skip the Haiku INCI extraction when the message has no ingredient list

Formula generation used to spend an extra language-model call on almost every request just to find out that the user had not listed any ingredients. A fast pre-check now answers that question, and the extra call is skipped only when it is confident there is no list; in every other case the previous behaviour is unchanged. Measured over 30 real-style requests: 18 skip the call, and no message that did contain ingredients was skipped. Live since 2026-09-22 (main 83245ca).

formulator-aigeneratejev
ENHANCEMENT

formulator-ai: the service's own INFO log lines never reached docker logs

The service's own INFO log lines were never written to the container log, because logging was never configured; only warnings and errors appeared. They are visible now, with the level controlled by the LOG_LEVEL environment variable, and third-party noise stays out. Live since 2026-09-22 (main 4b9bbd9).

formulator-aiobservability
ENHANCEMENT

formulator-ai: pick the /chat/generate playbook with TypeSafe Jev

Formula generation now picks the product-family playbook by meaning instead of by matching alias words, so a product only mentioned in passing ("not like a shampoo") no longer decides the chassis, and an SPF day cream gets the sunscreen playbook rather than the plain cream one. When the model is unsure the previous alias matching decides, exactly as before. Live since 2026-09-22 (main 03b7250); checked against all 116 existing playbook aliases with no change in their results.

formulator-aigeneratejev
ENHANCEMENT

The hair-dye system is inferred from the formula, and may only tighten a limit

Fixed and live. Annex III gives many hair-dye substances one limit in oxidative systems and another in non-oxidative ones. That is a property of the formulation, not of the product's name — an oxidative dye develops colour by reacting with a developer — so it is now read from the material list instead of from the product type. The markers come from the regulation itself: 18 substances appear only in oxidative scopes across the shipped exports, so their presence in a formula names the system. Hydrogen peroxide, the developer, is the second marker. The evidence has a known blind spot — an oxidative dye is usually sold as two parts, so a formula holding only the dye cream contains no developer, and the absence of a marker does not prove the system is non-oxidative. Because of that, this signal is allowed to **tighten** a limit and never to loosen one: where acting on it would raise a ceiling, it is refused and the previous, wider result stands. A wrong inference therefore costs a false alarm at worst, never a missed violation. The measured effect is small and was measured before the work: of 128 places this could apply, 122 change nothing. One substance genuinely benefits today — butoxyethanol, whose 2% non-oxidative ceiling is now enforced where the formula is a direct dye. Deployed 2026-09-18 and verified on the live service.

annex-iiiformulator-airegulatory-audit
ENHANCEMENT

Annex V "(as acid)" rows and the paraben sum ceilings were not enforced

Fixed. Annex V's "(as acid)" preservative limits are applied on the acid basis, and the paraben ceilings that govern a combined total are now enforced. Two gaps, both affecting everyday preservatives rather than specialist filters. **The acid basis.** V/12, V/12a, V/13, V/14 and V/18 express their limits as the acid. Parabens needed particular care: they are esters, so their basis is the parent 4-hydroxybenzoic acid — a reading the Regulation confirms in its own arithmetic, where the SCCS's "0,19% as esters" is converted to "0,14% as acid". Calcium salts needed care too: calcium undecylenate and calcium paraben each carry two acid units per molecule, and treating them as one would have cleared roughly twice the legal level. **The sums.** Annex V caps butyl- plus propylparaben together at 0,14%, and all parabens together at 0,8%. The audit checked one material at a time, so a formula spreading parabens across four entries read as four compliant materials. Methylparaben 0,43% with ethylparaben 0,47% and sodium methylparaben 0,05% is 0,821% as acid — over the limit, with every single component inside its own. The sums are taken on the acid basis, which matters in both directions: 0,4% methylparaben with 0,45% ethylparaben is 0,85% as declared but 0,737% as acid, and stays correctly clean. Code complete and on `main` (commits 3d6ac38 and ba8e2af). ⚠️ Not yet deployed — deployment requires a graph rebuild.

annex-vformulator-airegulatory-audit
ENHANCEMENT

Annex VI limits written "(as acid)" were compared against the salt, not the acid

Fixed. Annex VI ceilings written "(as acid)" are now measured against the acid rather than the declared salt. Five Annex VI rows express their limit as the acid, which does not count the counter-ion a salt carries. Comparing the declared salt percentage directly was always the strict direction — a false alarm, never a missed violation — but it rejected legal formulations. The clearest case is Neo Heliopan AP (Disodium Phenyl Dibenzimidazole Tetrasulfonate, entry VI/24), where the salt is the whole INCI and the ordinary use level of 10% is 9,35% as acid: perfectly legal, and previously reported as over-limit. The molecular weights sit in a curated data file with a PubChem reference for each number, so any figure can be re-checked without reading code. An ingredient that is not in that file keeps the old strict comparison — the system never infers a conversion, because a wrong weight in the permissive direction would hide a real violation. Findings now show both figures, e.g. "9.000% (8.332% as acid)". Code complete and on `main` (commit 4e68abe). ⚠️ Not yet deployed — deployment requires a graph rebuild.

annex-viformulator-airegulatory-audit
ENHANCEMENT

External API: allow labels to be changed on PATCH, not only set at create

The external API can now correct a ticket's labels, not only set them at creation. PATCH /api/v1/external/tickets/{displayId} accepts a `labels` array as a replace-set: omit the field to leave the labels untouched, send the full list to change them, send [] to clear them. Unknown names are created, exactly as at creation, and labels are applied before priority and status so one call can correct a label and close the ticket on the corrected set. punchline-mcp 1.3.0 carries the field through update_ticket. Label names are also trimmed and blank names refused, so a pasted " api" no longer becomes a second label indistinguishable from "api".

external-apimcppunchline

Bug fixes42

BUG

PIF PDFs hard-code English labels and punctuation: "Annex", "INN/EC/CAS:", "Created with", "1 page", colon spacing

Labels that the PIF/CPNP PDF built in code are now localized in all 24 EU languages: Annex citations ("Annexe III/98", "III. melléklet/98"), the footer page number and "Created with" credit, and the EC/INN identifier labels (fr "CE"/"DCI"). Colons follow each language's typography (French no-break space, no more "(ISO 24444).:"), the ✓ mark renders via a fallback font, and the Concentration column no longer breaks mid-word. Live since 2026-10-28 (API e2ba95e1).

piftranslation
BUG

PIF §2.8 reference footnotes print the untranslated note that §2.7 shows translated

PIF §2.8 footnotes now match the §2.7 table in non-English PIFs: the MAX/CIR note reuses the same translation as the §2.7 cell, and Annex rule texts (e.g. "Annex VI/30a: 25 % …") are translated with the Annex citation in the document's language. NOAEL references and notes entered by users stay as written. Live since 2026-10-28 (API e2ba95e1).

piftranslation
BUG

PIF templates: wrong article (9 → 19(1)(g)) in all 24 languages + French seed fixes from the Fable 5 review

The PIF template texts have been corrected in production. In all 24 languages, the sentence about the ingredient list now cites Article 19(1)(g) of Regulation (EC) No 1223/2009, the article that governs the ingredient list (it wrongly said Article 9 before). The French texts now follow the official French wording of the regulation: "Raisonnement", "formulation-cadre", "Formule quantitative et qualitative du produit cosmétique" and "rapport sur la sécurité du produit cosmétique". The animal-testing statement no longer claims that all data come from animal tests. New PIF exports use the corrected texts; PDFs already archived are not changed.

piftranslation
BUG

formulator-ai: manufacturer material pool includes private materials in generate prompts

Fixed and deployed on 2026-10-22 (formulator-ai devel 1815127, main d31abba, container healthy with 0 restarts). Measured on prod before the fix: 3 private materials were linked to one manufacturer through ManufacturerMaterial, out of 109 links in total, so the leak was real but small. MATERIAL_POOL_QUERY now requires m.is_private = 0. The pool is public-only, the same rule as the material playbook loader, because the JWT carries no team claim. The dead pool-chunk query (MANUFACTURER_POOL_JOIN) got the same condition, so a revived ingest stage cannot reopen the leak. The client-supplied pool fallback in resolve_manufacturer_pool is now documented as untrusted caller data; it only echoes what the caller sent. The graph's stocks edges still include private materials, but no prompt path reads them. Verified: in the running prod container, the affected manufacturer's pool resolves to 85 materials and contains none of the 3 private ones. Unit tests use the real SQL on SQLite, the full suite is green (2191 passed), and both filters are mutation-checked.

formulator-aiprivacysecurity
BUG

Allergen import: a CAS match picks an arbitrary INGSUB twin (by authId order), not the one whose name matches the row

Fixed and deployed on 2026-10-22 (formulator-ai devel b0beff1, main a66b590, container healthy with 0 restarts). The CAS fallback of match_ingsub no longer keeps the first record by authId. Every INGSUB record reachable through any CAS of the row's cell now competes. CosIng glossary entries (INGSUB.FUNCTIONS set) outrank the 2015 Annex/CAS-index twins, which have no functions (4,026 of 34,817 records). Among the remaining candidates, the closest name wins; ties go to the earlier CAS of the cell. Verified in the running prod container against prod INGSUB: CINNAMAL → Cinnamal, AMYL CINNAMYL ALCOHOL → Amylcinnamyl Alcohol, 3-PROPYLIDENEPHTHALIDE → Propylidene Phthalide, the Peru balsam row → Myroxylon Balsamum Pereirae Balsam Oil. On the full peppermint certificate, 14 of 113 rows change their pick, all to glossary names, giving 89 rows with no duplicate ingSubAuthId. 5 new unit tests, mutation-checked (5 mutants killed), and the full suite is green (2189 passed). Known soft spots: on 0 % botanical rows whose certificate line lumps several species together, the closest glossary name can still be a sibling part, e.g. "Narcissus Pseudo-narcissus Root Extract" or "Rosa Damascena Flower Extract" instead of "… Flower Oil".

allergenicsformulator-aiimport
BUG

Ticket description: 50000-character cap can overflow the TEXT column's 65535-byte limit

Closed: fixed by punchline ea29dd1f (2026-09-06, on master), using option 3. The description cap on ExternalTicketRequest (create) and ExternalTicketPatchRequest (patch) went from 50,000 to 16,000 characters. That fits the 65,535-byte TEXT column even with all 4-byte characters plus the composed "Reported by" header. In the same commit, public_summary was unified at 10,000. Minor remainder: the internal UpdateTicketRequest/CreateTicketRequest description still has no @Size. It is reachable only from the authenticated internal UI and can be tracked separately if wanted.

data-integrityexternal-apipunchline-api
BUG

formulator-ai: a test_allergenics_eval küszöbei flaky-k — az AI-fallback szórása átlépi őket

Closed: fixed by formulator-ai 070d424 (on main). The per-run row-count floor was replaced by a gate on the union of INCI names over N runs (default 3, ALLERGENICS_EVAL_RUNS). The stable core is reported on every run and gates at RUNS >= 5. The root cause is also gone: 2d69680 pins temperature=0 on the extraction calls (CV 0.0 % over 10 runs), and 97b1721 adds a deterministic row parser for this document family, so these documents no longer go to the AI fallback.

allergenicsextractionflaky-testformulator-ai
BUG

formulator-ai: a modell felülírhatta a szerver hivatkozásait a /chat/generate ágon

Closed: fixed by formulator-ai 8280399. The server is now authoritative for citations on /chat/generate and /chat/generate/stream (data["citations"] = list(citations) instead of setdefault), with 2 regression tests. The commit is on main and has been deployed since. The optional hardening (_audit_generated_formula should not read incoming citations at all) is defensive only and is not needed for correctness. If wanted, it can be tracked as a separate low-priority ticket.

citationsformulator-aiguard-asymmetryjavitva
BUG

canEditManufacturer drifts from backend: allows nonexistent material-admin-all, hides /manuf from manufacturer-editor

Closed: fixed and deployed on 2026-10-07 (rubedyne master e2e2c4e4, devel c41f7e90). canEditManufacturer and canReadManufacturer now match the backend's GLOBAL_MANUF_AUTHORITIES: manufacturer-admin, manufacturer-editor and admin are allowed. The nonexistent material-admin-all role was removed. The roles tests were red before the fix and green after, the build is OK, and the Vercel deploys succeeded. The ticket was only left open by oversight.

manufacturerroles
BUG

POST /manuf/update and /manuf/pif skip membership: any global editor can rewrite every manufacturer

Closed: fixed and deployed on 2026-10-07 (cosahh-rest devel f2533bc7, master e1a5bf1b). POST /manuf/create, /manuf/update/{authid} and /manuf/pif/{authid} now accept only edit:manufacturer / admin:manufacturer (GLOBAL_MANUF_AUTHORITIES), both in SecurityConfig and in @PreAuthorize. Proxy-level tests and a mutation check cover the gate, and the running WAR was verified. No current prod user lost access. The ticket was only left open by oversight.

cosahh-restmanufacturersecurity
BUG

An existing batch's worksheet (BPR detail page) is not reachable by navigation

An existing batch's worksheet can now be reached by navigation. On the Batches list (Manufacturing → Batches) the Batch ID opens the batch worksheet, with a small "Project" link underneath for the project page. On a project's batch section the batch number links to the worksheet too. Deployed to production (web master 16a8c5d6).

bprgmpnavigation
BUG

Deleting a batch (BPR) that has a Certificate of Analysis fails with a 500

Fixed with option (a). Deleting a batch that has a Certificate of Analysis is now refused with a clear message instead of failing with a server error. - **API:** `DELETE /flowlyzer/bpr/delete` answers 409 when the batch has a CoA. The body is `{"error": "This batch has a Certificate of Analysis and cannot be deleted."}`. The batch and its CoA are both kept, and no delete is logged. Batches without a CoA still delete as before. - **Web:** the project page toasts the API's reason instead of the generic "Failed to delete BPR.". **Tests** - 3 new API tests: a batch with a CoA, a legacy batch with a CoA, and a batch without a CoA that still deletes. All 24 BPR controller tests pass. Removing the new check makes 2 of them fail. - 2 new web tests. The full web suite (4333 tests) and the build pass. - No live delete of a real batch with a CoA was tried. **Deployed:** API master `466955ab` (healthy) and web master `7f39454e` (Vercel READY on rubedyne and skinlyzer). The devel commits are API `573bf230` and web `7481a546`.

bprcoagmp
BUG

/api/search is a dead debug endpoint that always returns 500 (handler destructures req/res from the first argument)

## Fix `pages/api/search.ts` was deleted. It was a dead debug route: - its handler destructured `req`/`res` out of the request object, so every call returned 500; - it only ever searched a hard-coded "phenoxyethanol"; - it had no auth and no caller. ## Guard New `__tests__/apiHandlerSignature.test.ts`. It parses every `pages/api/**` file with the TypeScript compiler API and fails if the default-exported handler takes a destructured first parameter. It sees through wrappers such as `withApiAuthRequired(fn)` and `export default handler`. - Six built-in samples prove it catches the pattern: 4 positive, 2 negative. - It also asserts that it found more than 300 route handlers, so a scan that finds nothing cannot pass. - It was red on `pages/api/search.ts:10` before the fix and green after. ## Verification | Check | devel | master | |---|---|---| | eslint | 0 problems | 0 problems | | tsc | unchanged (36 pre-existing test-file errors) | unchanged (36 pre-existing test-file errors) | | vitest | 541 files / 4303 tests green, no flakes | 541 files / 4303 tests green, no flakes | | `npm run build` | OK; route list shows 461 API routes, no `/api/search` | OK | ## Deploy - devel `cea01429`, master `5e5c4301` (cherry-pick). - Vercel production READY for both rubedyne and skinlyzer. - Live check: - `POST`/`GET https://rubedyne.com/api/search` now returns **404** (was 500). - Control: `/api/prod/search` returns 200 and the home page returns 200.

apitech-debt
BUG

GMP audit trail: Master and BPR activity-log entries are silently dropped

Fixed and deployed to production on 2026-10-12 (API `32dc5b99`, devel `9ff7b4de`). **What was lost** - All Master events: promote, approve, retire, draft edits, IPC changes. - All batch (BPR) events. - PIF link/unlink on projects. This case was not in the original report; the new guard test found it. - Project invitations: the entity was registered, but its config switch was never added, so it defaulted to off. **Fix** - The three missing entities are registered. - Every per-entity switch now defaults to on, so a switch missing from the config no longer silences an entity. Only `activitylog.enabled` turns logging off as a whole. - An unregistered entity name now logs a WARN instead of being dropped silently. - A new test scans all 38 `activityLogger.log(...)` call sites and fails if any entity name is not tracked. **Where entries appear** - Batch, PIF and invitation entries appear in the project activity feed. - Master entries appear in the admin audit log and in "My activity". **Notes** - Events from before this fix cannot be recovered. - A live check is still pending: the first real Master or batch action should produce an activity-log row.

auditflowlyzergmp
BUG

PIF TID export silently falls back to English when DeepL translation fails (Free API quota)

Fixed and live (cosahh-rest master 85d79fcb, Rubedyne 12ac8b24). - **Export is refused instead of shipping English:** a non-English TID or CPNP export (and a version archive) is refused when DeepL could not translate any regulatory passage. The response is 503 with code PIF_UNTRANSLATED, the count, the reason (QUOTA, NO_KEY, NETWORK or HTTP_xxx) and the language. Because it is non-2xx, the metered export token is refunded. Translations that succeeded stay cached, so a retry only requests the missing ones. English exports are unaffected. - **The UI explains it:** for example, "3 regulatory passages could not be translated into Hungarian (translation quota exhausted), so the TID was not created and no tokens were used. Please try again later." A "Report the issue" link opens /support pre-filled: Bug Report, PIF+CPSR, High, a title, and the PIF name and id, language, count, reason and time. - **DeepL endpoint and logging:** the endpoint follows the key type (":fx" uses the Free API, otherwise Pro), so switching to DeepL Pro only needs a key change. HTTP 456 (quota exhausted) is logged at ERROR. - **Tests:** written first: DeepL outcome, TID guard, controller 503, toast and pre-filled link. 183 targeted cosahh-rest tests and 3921 web tests pass, and next build is green. - **Live check:** a Hungarian TID export of the hero PIF through rubedyne.com returns a 504 KB PDF (200), with no translation failures logged. - **Not done:** DeepL Pro itself; the key is still Free, at 137k of 500k characters this month.

cosahh-restpiftranslation
BUG

Formula composition rows with equal rank load in a different order each time; manufacturing "Product family" auto-detects a W/O formula as O/W

Both parts are fixed and live. 1) Row order (Rubedyne master/devel 29b54bd0): - New lib/formula/compositionSort orders rows by conc, phase or name; ties fall back to the material name, then the row id. - Used by the composition table, the mobile view and the public formula page. - Concentrations compare as numbers (strings used to put "10" after "9"). - The manufacturing card's materials key no longer depends on row order. - Verified live on the hero formula: 4 reloads, 1 distinct order, no writes. 2) Product family (formulator-ai main d15ac57, prod rebuilt): - A formula that declares itself W/O in its name, category or labels now gets a W/O family before Jev and the aliases: wo-sunscreen for a sunscreen (sunscreen alias or "SPF"), otherwise wo-emulsion. - Short "W/O"/"V/O" count only at the end or before an emulsion word, so "Shampoo w/o sulfates" is untouched. - New playbook knowledge-base/playbooks/wo-sunscreen.yaml, composed only from sunscreen.yaml and wo-emulsion.yaml. Reviewed: pH 5.5-6.5 kept; the oil-phase range is noted as a REVIEW point. - Verified in the prod container: "...SPF 50+ W/O Emulsion" → wo-sunscreen, "Kézkrém V/O" → wo-emulsion, "Shampoo w/o sulfates" → none. Known limit: a W/O formula whose name, category and labels never say W/O (e.g. "Facial Sunscreen SPF 50+") still gets the O/W family automatically; choose the product family manually there.

formulation
BUG

Opening /user/notifications marks all notifications as read on page load (POST /api/notifications/read-all)

Fixed and live (master/devel 2a1caf9c, build 2026-10-06 10:11 UTC). Opening /user/notifications no longer marks anything as read. Unread items show a "New" flag with a "Mark as read" button, and the header offers "Mark all as read" while anything is unread. Both actions refresh the list and the unread badges. Frontend only; tested first (5 new tests), full suite 3853 tests, next build green. Verified live on the demo account: loading the page sends no write (0 non-GET requests, 0 guard violations). The two buttons were not exercised live, because the demo had no unread notifications.

notifications
BUG

Deleting a material always fails in production (delete proxy calls localhost:8000)

Fixed and live (e24071a3). The material delete proxy now uses `server` from config instead of `BACKEND_URL ?? localhost:8000`, with a regression test. Verified live: `DELETE /api/material/{authId}/delete` returned 204 and the material is gone from the DB.

materials
BUG

Material creators cannot edit their own materials in the UI (frontend edit gate ignores material-creator)

Fixed and live (52f5b705). The frontend `canEditMaterial` now accepts `material-creator`, which matches the backend's `edit:material`. The ownership check still limits editing to the user's own and team materials. Verified live: the demo team owner (material-creator) sees "Import from file" on its team material, and an allergen import succeeded.

materialsroles
BUG

Usage page shows weekly tokens used greater than monthly tokens used

Deployed 2026-10-04: the Usage page names the period each token counter covers and explains a week that started in the previous month, so weekly and monthly figures can be compared.

billingtokens
BUG

Inconsistent labels: "Manufacturing" eyebrow on the ingredients page; the AI formulator is called both "Formulyzer AI" and "Adeptus"

Deployed 2026-10-04: the advanced ingredient pages are labelled "Ingredients", and the AI formulator is called "Adeptus" everywhere (sidebar now links to /adeptus; saved sidebar customisations carry over).

copyui
BUG

New Rubedyne users received the Skinlyzer welcome e-mail (WELCOME_RUBEDYNE_CLIENT_IDS was empty in prod)

New Rubedyne users were receiving the Skinlyzer-branded welcome e-mail. The configuration is corrected: from 1 October 2026, everyone who signs up on rubedyne.com or the tutorial library gets the Rubedyne welcome e-mail.

authbrandingemail
BUG

Security: GET /tutorials/internal reached the tutorial detail endpoint without a JWT

Fixed an access-control gap in the tutorial library API. A specific path could reach the video details endpoint without signing in. No data was exposed: no clip used that path. The rule is now restricted and the path name is reserved, and the fix is deployed.

apisecuritytutorials
BUG

skinlyzer: the app-help corpus is 120 placeholders and 4 real documents, so navigation answers are guesses

The in-app help content behind the assistant's page suggestions was almost entirely placeholder text: of 124 pages only 4 had a real description, so answers about where to find something were inferred from page addresses — which is how one question produced two different wrong pages. Every page now has a hand-written description, derived from that page's own code: what it shows, what it can do, and which permissions or plans it needs. The content is live on the site and indexed for the assistant.

app-helpcontentformulator-ai
BUG

formulator-ai: an ingredient named in Hungarian denied its own Annex III limit

When an ingredient was named in Hungarian, the assistant could state that a regulated substance has no concentration limit at all — the same question asked with the INCI name, or in English, answered correctly. The cause was that ingredient recognition only matched the message against the INCI ingredient list, so a Hungarian name matched nothing and the authoritative database facts were never attached to the answer. The question's English search terms are now used for that recognition too, at no extra cost. Verified live: the salicylic acid question now returns the correct 3.0% limit with its Annex III entry, and a phenoxyethanol question is correctly refused at 1.5%. Live since 2026-09-23.

formulator-aihungarianrag
BUG

formulator-ai: Hungarian queries got no RAG keywords (fixed); app-help chunks still not retrieved for them

Questions asked in Hungarian were answered with much weaker grounding than the same questions in English, and never produced clickable page links. Two separate causes were found and fixed: the step that turns a question into search terms returned nothing for short Hungarian questions, and the assistant's page suggestions were being discarded because it named their fields differently when answering in Hungarian. Live check on the same three questions: two of them went from no links at all to the correct ones, citation counts rose from 3 and 5 to 8, and the third was unchanged. Live since 2026-09-23.

formulator-aihungarianrag
BUG

formulator-ai: /index/ingest is a silent no-op on production (stale checkpoint kept alive by a failing app_help stage)

The knowledge-base indexing on the production service reported success while indexing nothing, for any run since 2026-09-18. The cause was a help-content source still pointing at a developer address: that step failed on every run, which kept a resume marker alive, and each later run then replayed the old results instead of doing the work. The address has been corrected in production and the behaviour verified: indexing now completes properly and clears its resume marker. As a side effect the in-app help content was indexed for the first time (124 documents), so the assistant can now point users to the right page — a question about where to find a material's manufacturers now returns direct links.

formulator-aiingestops
BUG

A scoped annex row was measured against one ceiling for every product type

Fixed and live. An annex row that gives different ceilings for different product types is now measured against the ceiling that actually binds the product. The audit could previously tell only leave-on from rinse-off, and where every scope of a row is leave-on — the common case — it could not narrow at all. Across the shipped regulation exports, 193 of 275 such scopes were unclassifiable that way. Two substances show what it cost. **Benzophenone-3** is capped at 6% on the face but 2,2% on the body; a 5% body lotion was measured against 6% and passed silently, and is now reported as the violation it is. **Hydrogen peroxide** has six ceilings — hair 12%, skin 4%, nail 2%, oral 0,1%, eyelashes 2% — and a 3% toothpaste, thirty times over its 0,1% limit, was reported only as "verify which scope covers this". All six of its product types now resolve to the one ceiling that binds them. The change also removes false alarms: 3% hydrogen peroxide in a face cream is legitimate at the 4% skin ceiling and is no longer flagged. Where the product type cannot be placed, nothing is narrowed and the finding stays an explicit "verify this" — the system does not guess. Nothing needs to be sent differently from the application; the signals are read from the product type and category already provided. Deployed 2026-09-18 and verified on the live service.

annex-iiiannex-viformulator-airegulatory-audit
BUG

Annex VI limits that cap a combined sum were never checked against the sum

Fixed. Annex VI ceilings that apply to a combined total are now checked against that total. Seven Annex VI rows cap the sum of a substance and its nano form — titanium dioxide and zinc oxide at 25%, and two organic filters at 10%. The audit checked one material at a time, so 15% titanium dioxide plus 15% titanium dioxide (nano) read as two compliant materials rather than one 30% violation. That pairing is the ordinary coated-plus-nano mineral sunscreen. The finding names both contributing materials and deliberately carries no suggested maximum: the violation is certain, but reducing either one resolves it, so the choice stays with the formulator rather than being made automatically. Code complete and on `main` (commit 5458f95). ⚠️ Not yet deployed — deployment requires a graph rebuild.

annex-viformulator-airegulatory-audit
BUG

Annex VI per-product-type limits were ignored: every scoped UV filter row collapsed to its first percentage

Fixed. The audit now reads Annex VI's per-product-type limits correctly, and one UV filter that was invisible to it is indexed again. Annex VI writes its scope markers differently from Annex III (`a)` rather than `(a)`), so every scoped Annex VI row was collapsing to its first percentage. Benzophenone-3 in a body product was checked against 6% instead of the applicable 2,2% — a 5% body lotion passed clean. Octocrylene had the reverse problem: compliant 9,5% creams were rejected against a 9% limit that only binds propellant sprays. Separately, Annex VI/29 (Tris-Biphenyl Triazine) lists two forms in one cell joined by a slash, and nothing split them — the filter matched nothing at any concentration. Slash-splitting is applied only where a slash genuinely separates names, so entries like "Eucalyptus Globulus Leaf/Twig Oil" stay intact. Code complete and on `main` (commit 2a159b9). ⚠️ Not yet deployed — deployment requires a graph rebuild.

annex-viformulator-airegulatory-audit
BUG

formulator-ai: az LLM_TIMEOUT beállítás soha nem hat — a kód LLM_TIMEOUT_SECONDS-t olvas

The LLM request deadline is now configurable and actually takes effect. It moved to `llm.timeout_seconds` in `config.yaml` (override with `LLM_TIMEOUT_SECONDS`), the default rose from 120 to 240 seconds, and the value in force is logged at startup. Long analyses — a large composition with an extensive allergen list and wide reference context — previously failed with a server error just past two minutes, regardless of what had been configured.

configformulator-aisilent-misconfiguration
BUG

External API cannot close its own tickets in a triage-enforcing project — no way to record a priority ruling

I keep the triagle function, but disabled in the Rubedyne project.

apiexternal-apitriageworkflow
BUG

PIF SCCS/MoS table counts an ingredient twice when its material restates it as a declared allergen

The SCCS Calculations table double-counted any ingredient whose material both lists it in its INCI breakdown and declares it on its allergen sheet. On PIF 16 "Borsmenta illóolaj" is peppermint oil, so Mentha Piperita Oil was reported at twice its true amount — halving the Margin of Safety and running the annex ceiling checks against a doubled concentration. A declaration and a composition row are two statements about the same material, not two masses. The widening step now adds only the part a declaration contributes over and above what the declaring material's own composition already accounts for. The overlap is measured per declaring material, so an allergen dosed neat *and* declared inside a parfum still counts as the two real masses it is. Fixed in cosahh-rest (devel aa99932, master e91e140), covered by five new tests, full suite green. Because the correction is applied when the document is read, already-archived dossiers were fixed without re-freezing them. DEPLOYED and verified on 2026-08-25. PIF 16 was recalculated: Mentha Piperita Oil now reports 0.30 % — its material's actual content, counted once — with SED 0.391 and MoS 102.30, confirmed directly against the database. The buggy code would have produced 0.60 % and SED 0.782.

allergenscosahh-restpifsccsundeployed
BUG

Production MySQL is listening on all interfaces and reachable from the internet

MySQL is no longer reachable from the internet. Ports 3306 and 33060 are closed at the firewall, with the docker bridge networks and loopback explicitly allowed so every application keeps its database connection — no application config changed and nothing needed redeploying. Verified from off-host: both ports blocked, SSH and HTTPS unaffected, all 17 containers healthy with zero DB errors, and a DB-backed endpoint still returning 200. Rules persisted with netfilter-persistent. The fix originally proposed on this ticket (bind-address = 127.0.0.1) was deliberately not used: containers reach MySQL over the host's public IP, so it would have severed every application on the box. Direct Workbench connections to proding.co:3306 no longer work. Use Standard TCP/IP over SSH — SSH host proding.co:22 with the existing key, MySQL host 127.0.0.1 port 3306. Confirmed working end to end.

databaseinfrastructuresecurity
BUG

Reward sweep: a row whose billing resolution keeps throwing is retried forever and never quarantined

Fixed and deployed. Billing-owner resolution now happens inside the sweep's per-row loop, after the attempt has been recorded, instead of during candidate mapping upstream of it. A row whose resolution keeps throwing is therefore counted, backed off and quarantined at the attempt cap like any other poison row, rather than being skipped before its counter could rise and retried hourly forever. Fixed by removing the special case rather than adding a guard: the Candidate now carries the row's own user id, so a resolution failure is simply an ordinary per-row failure.

cosahh-restreward-sweep
BUG

Reporters can set their own reward tier: external-API tickets are auto-triaged from reporter-supplied priority

Fixed at the source and deployed. A SYSTEM service account filing a ticket no longer counts as a triage ruling, so a ticket filed through the external API or email intake arrives with triagedPriority null regardless of the priority sent. Only an AGENT or ADMIN ruling can set the field the token reward is tiered off, closing the self-service path for every caller rather than one. Verified in the deployed artifact; production exposure at fix time was nil. The cosahh-rest caller-side clamp remains as defence in depth and is tracked for removal in RUBE-40.

cosahh-restpunchline-apirewardssecuritytokens
BUG

Reward credits stranded forever: the reconciliation sweep for unapplied RewardGrants does not exist

Delivered and live in production. A scheduled sweep now settles reward rows whose token credit never landed, and it cannot double-pay: the durable half is guarded by a conditional applied_at stamp and the Redis half by a per-row credit-once marker. Schema applied, sweep enabled with an activation cutoff of 2026-08-19T18:23:24Z. Follow-ups tracked in RUBE-34 and RUBE-35.

cosahh-restdata-integrityrewardstokens
BUG

Lab Report templates and style config are global tables reachable by customer-side admins

Fixed 2026-09-29 (cosahh-rest 15e2daf3, deployed in master 24cb9ff5): lab report text templates and style config now require admin:pif-config (internal admin role only) on both the path matcher and a class-level @PreAuthorize. Customer-held admin:formula / admin:pif no longer reach them. Live check: anonymous requests → 401. Open follow-up: the authz smoke script has no lab-report rows yet.

labreportmulti-tenancyrubedynesecurity
BUG

Bug reward tier keyed off a reporter-settable priority

The bug-reward payout was tiered by a ticket's priority — a field reporters can set themselves when filing. Since closing a ticket never required re-checking that priority, a reporter-filed "CRITICAL" could be closed as-is and pay the top reward on a number the reporter chose. The reward now reads a separate priority that only staff can set, recorded when someone with edit rights rules on the ticket. Until that happens the ticket is untriaged and pays nothing, so the reward is something a team member grants rather than something a reporter proposes. Caught by an adversarial review before the feature shipped, so no tokens were ever paid out this way. Fixed in both the sender and the receiver, with tests covering the case where staff confirm a reporter's priority unchanged — which previously would have left the ticket unrewarded.

punchlinerewardssecuritytokenswebhook
BUG

PIF renders and archives from the live formula instead of the frozen snapshot

Implemented and merged to `devel` in both repos (cosahh-rest `bef37f8`, skinlyzer `99c9ff2c`, both pushed). **NOT DEPLOYED** — see outstanding items below before treating this as shipped. A formula edit leaked into a newly archived PIF version PDF. The freeze machinery existed but was wired only into the display path; every render path resolved live, and the archive simply delegated to those. Fixing the service layer alone was not enough: `PIFTidPdfService` independently re-read the live graph for §2.1, §6.1, §7 and the allergen tables, so the very section the snapshot exists to reproduce would still have rendered live. Delivered: a `CompositionRow` seam with two sources (live formula / frozen rows) and two projections; new `PIFFormulaMaterial` and `PIFFormulaAllergenic` tables; `generate()` taking the frozen composition, per-occurrence raw materials, allergen totals and threshold as parameters, with `ForMatRepository` no longer injectable into the renderer so the bug cannot silently return; `ensureFrozen` in its own transaction across nine entry points; and the export gate moved to freeze time. Frontend warns in the New Version dialog when the formula has drifted. Backend 1068 tests green, frontend 286 files. **Outstanding before this is live:** 1. Apply `AddPIFFormulaMaterial.sql` — but reconcile against production first. `mvn test` was booting a `@SpringBootTest` against the production datasource with `ddl-auto=update`, so Hibernate very likely created these objects already. The migration is idempotent and carries `MODIFY COLUMN`, but the live schema must be diffed against it. 2. Deploy cosahh-rest and skinlyzer together. 3. **The manual end-to-end smoke has never been run** — it needs a local MySQL, and this machine's default datasource is production. It remains the only end-to-end proof the fix works. Release-note items (every existing PIF's freeze point resets on deploy day; a pre-drifted PIF freezes from the edited formula; MaterialAllergenic corrections no longer reach a PIF until Reload; allergen totals may shift) are documented in `skinlyzer/docs/superpowers/plans/2026-08-08-pif-frozen-formula-DEPLOY-NOTES.md`. Related: RUBE-24 (the fingerprint work that exposed this).

complianceformulapifrubedyne
BUG

PIF formula change detection must track material changes, not the derived INCI list

Delivered and on `master` in both repos. PIF drift detection compared the saved snapshot at INCI level, so swapping raw material A for material B with an identical INCI profile at the same effective concentration left the badge green and the Reload button disabled — the PIF silently kept referencing a material no longer in the formula. Now a SHA-256 fingerprint of the formula's per-material composition is persisted on `PIFDocument` and recomputed from the live formula on read. `GET /pif/{id}/formula-sync` serves it; the frontend consumes it through a single SWR hook. Material swaps, concentration changes, MatIng edits and phase changes are all detected; row re-ordering is not a false positive. Detection is not retroactive — it begins from the first read after deploy, since no material-level history existed to compare against. Documents predating the fingerprint fall back to the old INCI-level comparison until they are frozen, which is why the legacy `PIFFormula` table is deliberately retained. Closing this one scoped to the fingerprint work. The render-path defect it exposed — that the frozen snapshot was never consulted when generating the PDF — is tracked separately as RUBE-27, now linked.

complianceformulapifrubedyne
BUG

PIF document images render as "[Image not available]" in the PIF & CPSR PDF

Fixed in cosahh-rest `devel` commit `e465472`. PIF documents are uploaded to Cloudinary with `access_mode=authenticated`, so their stored URL answers 401 to an unsigned server-side fetch. The PDF renderer fetched that URL directly, so every embedded PIF image failed and fell back to the "[Image not available]" placeholder. Downloads always worked because the UI goes through the frontend's signing proxy — the renderer was never taught to do the same. The renderer now signs the URL first, via a new `CloudinaryService.signedRawUrl` that mirrors the frontend signer. Backend suite green at 1073 tests. Not yet deployed. Two related gaps were found and deliberately left for separate tickets: the Annex document links in the PDF are the same unsigned URLs and will 401 when clicked, and the image filter still admits `webp`, which OpenPDF cannot decode.

cloudinarypdfpifrubedyne

Under the hood6

TECHNICAL

formulator-ai: the allergenics AI extraction runs at the provider's default temperature — pin it to 0 before accepting N-run redundancy as the answer

Closed: implemented in formulator-ai 2d69680 (on main). Note: that commit message mistakenly says RUBE-56. Both allergenics LLM calls now pass temperature from llm.extraction_temperature (default 0.0, override EXTRACTION_TEMPERATURE). It is resolved per call, not at import time. Measured over 10 runs per document: Lavender [77]x10 and Peppermint [113]x10, CV 0.0 %, down from 18.5–18.7 %. In addition, 97b1721 adds a deterministic row parser for the CAS-per-line family, so these documents no longer depend on the LLM. Optional follow-up: lower llm.allergenics_fallback_runs from 3.

allergenicscostextractionformulator-aireliability
TECHNICAL

Remove dead /api/manuf/pif-update proxy (no caller)

Closed: both parts were removed and deployed on 2026-10-07, web first. rubedyne master 51905308 deleted the dead /api/manuf/pif-update proxy, which now returns 404 live. cosahh-rest master 9fcefe67 removed POST /manuf/pif/{authid} and its SecurityConfig matcher, and the running WAR was verified with javap. The ticket was only left open by oversight.

cleanupmanufacturer
TECHNICAL

Clean up remaining ESLint warnings (~650, mostly no-explicit-any) now that build-time lint is live again

## Result Build lint (`next lint`) went from **644 warnings to 0 problems**. A plain `npx eslint .` over the whole repo also reports 0. `@typescript-eslint/no-explicit-any` is now an **error** outside tests, so a new `any` fails the build and the Vercel deploy. ## What changed - **New `errorStatus(err, fallback)` helper** in `lib/apiError.ts`, with tests. It replaces the untyped `catch (e: any) { … e.status || 500 }` pattern; 209 catch blocks were converted mechanically, and the rest by hand. - **The remaining ~430 `any`s were replaced with real types.** These include existing DTOs, Stripe SDK types, `NextApiRequest`/`NextApiResponse`, small local row types, and `unknown` with narrowing. - **Error messages** are now read via `e instanceof Error`, keeping the same fallbacks. - **The changes are type-level only, with one exception:** the manufacturing-process generator now sends `conc` as a number. That only makes a difference if a concentration ever arrives as a string. - **Non-`any` findings fixed:** - 2 `rules-of-hooks` errors (unsaved-guard test stub). - The `exhaustive-deps` warning in `FormulaDetailPage` (stable `allowNext`). - 3 `<img>` warnings, now suppressed with a reason on each line. - The unused `eslint-disable` in `scripts/eval-concept-resolution.ts`. - The anonymous default export in `eslint.config.mjs`. - **Guard:** `__tests__/eslint-config.test.ts` asserts the rule severity per file kind. The CLAUDE.md "Build-time lint" section documents the rule. ## Verification | Check | devel | master | |---|---|---| | eslint | 0 problems | 0 problems | | tsc | unchanged (36 pre-existing test-file errors) | unchanged (36 pre-existing test-file errors) | | vitest | 540 files / 4296 tests green, no flakes | 540 files / 4296 tests green, no flakes | | `npm run build` | OK | OK | A deliberately added `any` was confirmed to fail `next lint` with exit 1. ## Commits / deploy - devel `c9fe59cc` (4 commits) - master `11dfc5ec` (cherry-picked) - Vercel production READY for both rubedyne and skinlyzer. ## Side finding (not changed) `pages/api/prod/search.ts` destructures `req`/`res` from the handler's first argument, so `res` is always undefined. It looks like an existing bug and is worth its own ticket.

linttech-debt
TECHNICAL

next build lint step fails on its own ESLint config ("Could not find plugin react"), so build-time lint is off

Fixed and deployed to production on 2026-10-12 (web master `5d98743c`, devel `84866e3a`). Vercel production is READY for both rubedyne and skinlyzer. **Root cause:** the project rule block in `eslint.config.mjs` had no `files` scope, so it also applied to `lib/legal/*.cjs`. eslint-config-next never registers the `react` plugin for `.cjs`, so `react/jsx-key` could not resolve it and ESLint aborted while loading its config. `next build` only printed `⨯ ESLint: …` and skipped linting. The full `next lint` and the ESLint CLI failed the same way. **Fix** - The block is now scoped to `**/*.{js,jsx,mjs,ts,tsx,mts,cts}`, the same extensions eslint-config-next covers. - New `__tests__/eslint-config.test.ts` loads the config for each kind of file. It failed before the fix and passes now. - The now-working lint found 5 real errors, all fixed: - two unescaped apostrophes; - a `useMemo` the React compiler could not preserve; - a test assigning a variable outside its component; - the Auth0 logout `<a>`, kept as a full page request with a justified eslint-disable. - `CLAUDE.md` documents the lint gate. **Result** - `next lint` passes with 0 errors on both devel and the master tree, and `npm run build` passes on both, now with real linting. - ESLint errors fail the build again. - 644 warnings remain. They do not block the build and are out of scope here.

buildtooling
TECHNICAL

cosahh-rest port 8000 is exposed to the internet in cleartext alongside the TLS proxy

Fixed and deployed. The container's port 8000 is now bound to 127.0.0.1 instead of 0.0.0.0, so the app is no longer reachable from the internet in cleartext. Apache is unaffected — it terminates TLS and proxies to http://127.0.0.1:8000/ for both :80 and :443. Verified from off-host after deploy: https://proding.co/ serves normally, the webhook receiver still answers through the proxy, and http://proding.co:8000/ is refused. Dependents were enumerated before the change: no container env referenced the published port, nothing in-repo hardcoded it, the healthcheck runs inside the container, and the webhook is registered against the HTTPS URL.

cosahh-restinfrastructuresecurity
TECHNICAL

The only test that executes the credit-once Lua script runs in no automated build

Fixed and deployed. An "it" Maven profile now runs RedisTokenCounterStoreIT — the only test anywhere that executes the credit-once Lua script — via `mvn verify -Pit`. The profile deliberately skips surefire, because a plain `mvn verify` would run the unit suite and this project's datasource defaults to the production database, so an unscoped run boots a Spring context against prod. The command is recorded in CLAUDE.md and in the activation runbook's pre-flight checks rather than only in a commit message, so it is findable by whoever needs it before a deploy that touches the counter store or the reward policy.

cosahh-restreward-sweeptesting