Skip to content

Project-level findings — raise once, not per feature ​

Running notes from the 2026-07-30 overnight batch. These are defects that are architectural rather than feature-specific: every feature audit hits them, so filing them per feature would bury the real per-feature work. Confirmed by two or more independent agents unless marked otherwise.

playout ​

  • CONTENT-01 — CI checks key parity, not content parity. check-locales.mjs compares keys only. fr.yml and no.yml carry the keys with verbatim English values marked # TODO: translate — 298 of 639 keys repo-wide (one agent counted 639 such lines in no.yml). A French or Norwegian user sees English across roughly half the app while CI stays green. Confirmed independently by the tenant-chooser and event-list audits.
  • NAV-03 — no per-route document title mechanism exists at all. Not a missing title on one route; there is no mechanism. Two agents.
  • A11Y-03 — @playout/ui custom triggers are bare <div @click>.MenuDropdown's trigger has no tabindex, role, aria-expanded or Escape handling, so anything built on it is mouse-only. Fix likely belongs in @playout/ui, not in the consuming views. Tenant-chooser audit; event-list found the same shape on calendar chips.

@playout/ui — fix upstream, not in consuming views ​

Four agents independently traced findings into the shared component library. These recur in every playout feature that uses the component, so they should be one issue each against @playout/ui, cross-referenced from feature drafts:

  • VDialog hides closed dialogs with opacity only — a closed dialog stays tab-reachable and its controls can be activated unseen (VDialog.vue:20-40).
  • VDialog binds Escape globally, unguarded, never unbound — on the Bible overlay, Escape anywhere on the page fires the verse picker's cancel(), writing a stale-or-null reference to the live on-air document. Rated Blocker by that audit. Songs / Queue / Custom overlays share the component.
  • VDialog's #header slot breaks aria-labelledby — the dialog has no resolvable accessible name.
  • VTitle hardcodes <h1>, producing two <h1>s per page in views that also render a page heading — A11Y-04 across the whole app.
  • VButton loses focus when disabled — the FORM-06 focus-drop, repo-wide.
  • MenuDropdown trigger is a bare <div @click> (above).
  • Global esc binding collides and leaves Escape dead on the route — a fourth agent traced this to the same shared dialog code. Distinct from the Bible-overlay symptom (Escape firing the wrong handler); here Escape does nothing at all.

Separately, Mousetrap bindings are registered on mount and never unbound in at least five views (LowerThird, Songs, SongSelector, Bible, Queue). ctrl+f is bound twice, so the advertised shortcut no-ops depending on mount order, and browser Find stays suppressed app-wide after leaving the route.

customer-portal ​

  • MSG-02 — getErrorMessage prefers the raw backend string over the localised fallback by design, so SDK/network prose reaches users on every failure path, untranslated for nb. Helper-level, not per-page. Sign-in, sign-up and email-verification audits.
  • NAV-03 — one static document title for the whole app. Three agents.
  • packages/ui DataTable + domain-helpers carry shared defects hitting ~15 list features. The catalog audit recommends promoting these rather than repeating them per page — see drafts/customer-portal--catalog.md findings 4-7 for specifics. Any list-feature audit should cross-reference that draft instead of re-deriving. One confirmed instance: error copy on DataTable pages interpolates TanStack's status string, so users read "(status: error)" and the 403 branch is unreachable dead code.
  • List state is component-local everywhere — no URL sync, no KeepAlive (AppLayout.vue:445), so Back from any detail page resets search, filters and pagination. Confirmed on catalog and marketplace; likely every list feature.
  • A11Y-04 — no <h1> anywhere — CORRECTED, do not treat as project-wide. The bids audit checked and found @aadalen/ui PageLayout:103 and MobileListHeader:50 both render a real <h1>, so any page built on those shells passes. The original claim came from the auth pages (sign-in, sign-up, verify-email), which sit in DefaultLayout/OnboardingLayout and do not use those shells. Scope: the auth and onboarding shells only. Judge each page on which shell it uses rather than assuming.

members ​

  • CONTENT-01 — no i18n layer at all, French literals by design. Per BASELINE.md, raise once for the project; feature drafts mark it not-applicable.
  • widgets/src/api.ts surfaces raw HTTP <status> strings to users across every widget. MSG-02 at the shared-client level.
  • NAV-03 confirmed — no per-route title mechanism; admin/index.html:13 is a static « BCC Nancy Admin » for all 24 routes. (Completes the cross-project table: all four projects fail NAV-03.)
  • Currency precision does NOT affect the admin SPA. admin's own useCurrency.ts uses maximumFractionDigits: 2, and no admin file imports the widgets' useFormat.ts. The rounding defect is widgets-only. Recorded so the alignment pass does not over-claim.
  • TableData.vue is the members equivalent of customer-portal's shared DataTable cluster — one file, ~15 admin routes. Three agents traced findings into it:
    • :118-140 never reads isError, so a failed load renders as « Aucun élement » — including on money worklists, where callers compute and pass an error that lands in dead code.
    • Row edit, sort headers and row actions are mouse-only (right-click is the sole path to Split/Supprimer on Transactions).
    • FilterDrawer focus handling is broken. One fix covers members queue rows #10–#29. Do not re-file per feature.
  • Undeclared suborg prop — cross-sub-org data exposure. DonsProgress.vue reads (props as any).suborg, which is never declared in defineProps, so the value from the host page's mount attribute is silently dropped and the widget always authenticates as b-active. It can therefore display another sub-organisation's donation totals. The same undeclared-prop pattern was reported in four sibling widgets — any members audit should check its own widget's defineProps against every attribute the host page can set, and say explicitly whether it is affected. Proposed as DATA-01.
  • Loading gates that ignore the error branch. DonsProgress.vue:6 gates its skeleton on a data value rather than a load state, so a failed fetch shows the skeleton forever and the error component below is unreachable dead code. The same shape appears in objectifs.store.ts and MyDecharges.vue. This is the members instance of the cross-project MSG-06 theme.
  • Currency precision. useFormat.ts:2 sets maximumFractionDigits: 0, so 12,50 € renders as « 13 € » while the server charges 12,50. Confirmed by two agents (events checkout, donation progress). Shared formatter, so every money-displaying widget inherits it.

tt-time-tracker ​

  • CONTENT-01 — no i18n layer, French literals by design. Same treatment as members.
  • NAV-03 — no route sets a title; index.html:17 is « Tim » everywhere.
  • MSG-06-shaped split within the project — AMENDED, it is not a clean admin-vs-worker line. ListErrorState and Table.vue's error/retry props exist, but are applied inconsistently within screens, not just between them:
    • ProjectList wires ListErrorState correctly; ProjectDetails uses it on neither of its two tables nor its root query, so a failed fetch renders a blank route.
    • Dashboard.vue's summary half has a real error surface with a retry; the entry list on the same screen swallows both fetch errors into « Aucune entrée ». So the finding is "the error components exist and are not used", which is a stronger and more actionable framing than "worker screens lack them".

Cross-project alignment candidates ​

Ordered by how many projects are confirmed affected.

Themeplayoutcustomer-portalmemberstt-time-tracker
SEC-01 account enumeration (distinct wrong-password vs no-such-user)✗ fails✗ fails—✗ fails
SEC-02 reset token not verified before the form renders(unmerged branch)✗ fails—✗ fails
MSG-06 non-throwing SDK result discarded, failure shown as success✗✗✗✗
CONTENT-05 currency precision~ formatter ok, bypasses unchecked✗ 9 sites✗ widgets + .toFixed(0) bypass in admin?
Persisted scope never revalidated (tenant / account / org from storage)✗✗—✗
Zero-scope dead end (user belonging to no tenant/org loops, no sign-out)✗——✗
NAV-03 per-route document titles✗ none✗ one static✗ one static✗ one static
MSG-06 failed load renders as empty state✗—✗✗ partial
A11Y-03 click-only div/li rows and chips✗✗✗✗
404 catch-all route✓ has (but see below)✗ missing✗ missing✓ has
Forbidden/unauthorized surface✓ has✓ has✓ has✗ silent redirect

Proposed-rule ID collisions — MUST be reconciled before anything is filed ​

Agents worked independently and reused the next free IDs, so the same ID now means different things in different drafts. Do not merge these into BASELINE.md as-is. Reconcile into one coherent set, then rewrite the citing drafts.

  • MSG-06 proposed as: (a) failure never rendered as empty/permanent-loading; (b) failed fetch must not render as empty state; (c) a router-enforced gate must expose the escape hatch that clears it; (d) result-returning SDKs must be inspected. (a), (b) and (d) are one rule; (c) is a different rule.
  • NAV-06 proposed as: (a) strip consumed token from URL; (b) async guard gates bounded by timeout + retry; (c) await resolved auth state before branching; (d) persisted navigation defaults only from deliberate user choice; (e) view mode/tab reflected in URL. At least four distinct rules.
  • FORM-12 proposed as: (a) capture= suppresses gallery; (b) consent link must resolve for every renderable value; (c) inert controls (filters that never reach the API); (d) warn before discarding entered form data; (e) sign-in offers a provider that sign-up does not.
  • CONTENT-05 proposed as: (a) currency precision / shared money formatter; (b) no terms/privacy link or consent record; (c) mode switches labelled by situation not implementation.
  • A11Y-07 proposed as: (a) <html lang> reflects active locale; (b) viewport must not disable zoom; (c) alt="" on meaningful images; (d) toggle state exposed programmatically; (e) selection state not colour-only.
  • SEC-06 proposed as: (a) credential endpoints rate-limited; (b) capacity limits enforced server-side; (c) federated entry points record the provider so logout ends the IdP session; (d) purpose-specific consent as explicit affirmative choice, never an unticked default.

Standing caveat affecting many drafts ​

Exception — playout's component library is in-repo. packages/ui and packages/schemas are workspace packages inside the playout repo, so VAlert's missing role="alert", VButton's native disabled, VTitle always rendering <h1> and the VDialog defects are asserted from source, not Unverified. This exception does not extend to the other three projects, whose external libraries (PrimeVue, @bcc-code/component-library-vue) cannot be read.

customer-portal is a partial exception too. Its own packages/ui is an in-repo workspace package, so PageLayout.vue:103 (real <h1>), MobileListHeader.vue:50 and DataTable.vue are readable from source and should be asserted. Only PrimeVue internals — Button :loading→disabled, Message's ARIA role, Card/Panel title elements — remain genuinely unverifiable there. Three separate agents flagged this; drafts written before this correction may under-claim.

node_modules is not installed in any of the four checkouts. Agents therefore could not read PrimeVue / @bcc-code / @playout/ui internals from source, and have correctly flagged the affected claims under Unverified rather than asserting them. Recurring examples: PrimeVue Button :loading → disabled (FORM-06), Message carrying role="alert" (MSG-01), Card title element (A11Y-04). Running pnpm install in each repo would let a follow-up pass settle these.

Emerging rule family: LIVE — broadcast/on-air surfaces (playout only) ​

Two agents independently proposed a LIVE-* family that BASELINE.md has no equivalent for, because no other project in the programme has an on-air state. Worth adding as a real section rather than folding into MSG/A11Y:

  • LIVE-01 — what is currently on air is always visible, and a control that clears output is distinguishable from one that merely navigates.
  • LIVE-02 — live state is never conveyed by colour alone, and transitions on/off air are announced.
  • LIVE-03 (from the "Show replaced in place by Hide" finding) — an inverse control must not replace its counterpart in the same position, or a double-tap reverses the action just taken.
  • Related, already proposed under NAV: global keyboard shortcuts must be unbound on unmount. Mousetrap ctrl+f is bound in five playout views and never unbound, so browser Find stays suppressed app-wide after leaving the route.

Also recurring in playout: success feedback fires before the write resolves (haptic + audit-log entry recorded for air time that never happened). That is the same shape as MSG-06 elsewhere and should merge into it.

Currency precision — confirmed in two projects, verify in the other two ​

maximumFractionDigits: 0 applied to money that is stored and charged with decimals. Confirmed by measurement, not inference:

  • members — shared widgets/src/composables/useFormat.ts:2; 12,50 € renders as « 13 € » while the server charges 12,50. Two agents.
  • customer-portal — nine hand-rolled formatters; an agent verified in node that 499999.60 renders as "500 000 kr". Worse than members because the bid input accepts øre, so re-saving a decimal bid silently rewrites the amount to a different number.

Method correction — a correct shared formatter is not a pass. The cotisations audit found members' admin useCurrency.ts is correct at 2 digits, yet Cotisations.vue:136 bypasses it with .toFixed(0) on a Decimal(10,2), so the generated reminder email quotes 12,50 € as "13€". A second bypass sits at sponsor/SponsorApp.vue:421.

Consequence: the earlier "playout passes" result is provisional. It was established by reading currency() only. Any remaining audit touching money in playout or tt-time-tracker must grep toFixed(, Math.round( and parseInt( against money values, not just inspect the shared formatter, and report explicitly. Do not record a clean pass for either project on formatter-inspection alone. Both display money (playout has no obvious money path; tt has invoices in two places). Any audit touching a money surface in those two should grep for Intl.NumberFormat and maximumFractionDigits and report explicitly, so the final table has four real answers rather than two blanks.

Persisted scope selection — three projects, same shape ​

Each app remembers which tenant / customer account / organization you last used by writing an id to localStorage, then trusts it on the next boot:

  • playout — localStorage.tenant, written by router.afterEach for any:tenant route, including the unprotected public screen URLs. A public display link can silently reassign your default.
  • customer-portal — admin-selected-account; the guard tests only that the key exists, never that the account is still in the user's active links, and sign-out never clears it (next user on the device inherits it).
  • tt-time-tracker — remembered org id read at store construction; the revalidation branch in App.vue:104-108 is unreachable behind an early return at :96. A revoked user keeps the ex-org's name, logo and theme while every request 403s and the error is swallowed.

Three distinct symptoms, one root cause: a stored scope id is treated as authoritative instead of as a hint to be validated against current memberships. Strong candidate for a single new baseline rule, and the fix is the same shape in all three.

Related, confirmed in playout and tt-time-tracker: a user belonging to zero tenants/organizations hits a dead end — the chooser has nothing to choose, and because the route sits outside the authenticated layout there is no sign-out control, so it loops with login.

Correction: playout is NOT the reference implementation for error states ​

Earlier notes in this file treated playout as the model for the 404 alignment issue, on the grounds that it has a catch-all route where customer-portal and members do not. The error-states audit shows having the route is not the same as handling the case:

  • ErrorHero.vue has zero interactive elements, and both not-found and unauthorized sit outside Layout, so they are chrome-less dead ends with no link out — a direct MSG-04 failure.
  • The unauthorized route is unreachable dead code: nothing in src/ navigates to it. Real denials render a different, hardcoded-English 403 inline at Layout.vue:22-27, so the translated error.unauthorized* keys shipped in all three locales are never shown to anyone.
  • NotFound.vue ignores the error.not_found* keys that exist in all three locales and uses English literals instead.

Consequence for the alignment pass: the recommendation is "every project needs a 404 that says what happened and links out", not "copy playout". No project currently has a reference-quality implementation; tt-time-tracker's is the remaining candidate and has not been audited yet.

Open cross-project hypothesis: does "archive/disable" actually revoke access? ​

The tt-time-tracker Users audit found that archived is a display flag only — OrganizationGuard.resolveRoleAndUser and getUserRole never read it, no sessions are deleted, and the API's DELETE is also just an archive. An offboarded employee keeps signing in and logging hours while being invisible to every admin screen. Rated Blocker.

This is the kind of defect that hides in every admin surface in the programme. Any audit touching a user/member/admin management screen in the other three projects should explicitly check and report: when the UI says removed, archived, deactivated, revoked or disabled, does an auth path actually consult that state, and are existing sessions ended? Report the answer either way — a confirmed pass is as useful here as a failure.

Answers so far ​

  • tt-time-tracker — FAILS (Blocker). archived is a display flag; no auth path reads it, no sessions deleted, API DELETE is also just archive.
  • customer-portal — PASSES, and is the reference implementation.PermissionsService.buildContext re-reads user, customerLinks and roles from Postgres on every ability-gated request and filters status === "linked" (permissions.service.ts:66). There is no session-time permission snapshot, so unlinking revokes data access on the very next request. Hard delete cascades Session + Account, so it genuinely revokes. Recommend this shape as the fix for tt-time-tracker's OrganizationGuard. Residual gap, filed separately: unlinking does not sign the user out — they still authenticate, fall back to base role customer, and land in an empty portal with no admin-side warning.
  • playout — different failure. Not a stale-flag problem but a missing last-admin guard (see below); removal notifies nobody and leaves no record.
  • members #25 Users & roles — still to audit.

Caveat worth carrying into the alignment issue: customer-portal passes on the permission path while still failing the same class of test on its admin-selected-account localStorage path (see "Persisted scope" above). The lesson is that revocation has to hold on every path that grants scope, not just the one the permission system owns.

Emerging theme: aggregates computed from one page of data ​

Three independent sightings, presented to users as authoritative totals:

  • customer-portal dashboard — hero counts are .length of server-truncated lists (take:10, take:5), so "N items need your attention" silently caps.
  • customer-portal trade-in machines — filters apply to one 50-row page while the count and empty state claim the whole data set.
  • tt-time-tracker projects — "Coût HT" sums only the default 50-invoice page; "Équipe" counts only the 10-row entries page.

One rule covers all three: a displayed total, count or aggregate must either cover the whole data set or say what it covers. Silent truncation is worse than a missing number because it looks authoritative. Candidate name: DATA-TRUTHFUL-AGGREGATE (reconciling the separately-proposed DATA-TRUTHFUL-COUNT, FILTER-SCOPE and DATA-PAGE-AGGREGATE).

Single most serious finding of the batch — fabricated customer-facing data ​

customer-portal, LiveTrackingPage.vue (queue #14, drafted).

The page presents synthetic telemetry as real: operating hours, battery and fuel percentages, active/idle state and "last update" timestamps are all derived from a hash of the asset id, and map pins are guessed by string-matching the asset's location name against roughly 20 hardcoded city coordinates. The UI labels this "real time".

Mitigating: the route's sidebar entry is deliberately hidden pending release (AppLayout.vue:122, visible: false). Aggravating: the route still resolves, is gated only on read CustomerAsset, and is therefore reachable by any customer who follows or guesses the link — and it is the one defect in this programme where a user is shown invented information about their own physical equipment and has no way to tell.

Proposed rule DATA-SYNTHETIC: placeholder, sampled or derived data must never be presented as measured. If a surface ships before its data source exists, it must say so on the surface itself, not only in a hidden nav flag.

Recommend this is the first thing reviewed in the morning, and that the decision about whether the route should resolve at all is made before the feature is released rather than after.

playout — the unmerged password-reset PR has a live consequence ​

Two findings from different audits combine into a user-facing dead end that neither shows alone:

  1. Profile.vue lets a user create email/password credentials on their account (linking a password to an existing SSO login).
  2. The repo contains no updatePassword and no sendPasswordResetEmail call anywhere, and develop has no forgot-password / reset-password routes — those live only on the unmerged atlas/issue-436 (PR #437).

So on develop as it ships today, a password set via Profile can never be changed and never be recovered. The user's only route back is an administrator.

This reframes PR #437 from "a UX improvement awaiting merge" to "the fix for an active lockout path". Worth saying explicitly when the playout drafts are reviewed — the queue row currently reads as though the password-reset work is done, and it is not on the audited branch.

Open cross-project hypothesis: is there a last-admin guard? ​

playout's Admin management has no last-admin protection client- or server-side: an admin can remove or demote themselves, and a single-admin tenant locks itself out permanently — recoverable only by a platform super-admin.

Pairs naturally with the offboarding-revocation hypothesis above; both are "the admin surface lets you destroy your own access". Any audit touching a role/permission/admin surface should check and report: can the last remaining admin/owner remove or demote themselves, and is the guard server-side?

Screens still to audit where this applies: customer-portal #35 Roles & permissions, tt-time-tracker #21 Organizations, members #25 Users & roles.

Nuance to preserve: the Pinia destructure is conditionally a defect ​

Two agents met the same code — const { accountId } = useAdminAccountStore(), which destructures a reactive store and loses reactivity — and reached different, both-correct conclusions:

  • Dashboard audit: not user-visible. AppLayout.vue:446 keys <RouterView> on an account-switch counter, so the whole page remounts and re-reads the value. Recorded explicitly as a non-defect.
  • Service plans audit: user-visible and a Blocker. The author's watch(() => accountId, loadData) never fires, and — critically — the value is read again at write time, so plans and checklists created after an account switch are POSTed with the previous customer's id.

Do not collapse these into one finding, and do not let the dashboard's non-defect verdict suppress the service-plans one. The rule that covers it is about where the stale value is consumed: a remount saves you on read, nothing saves you on write. Proposed DATA-SCOPE-01 — the active scope must be read reactively at every use site and re-read immediately before any write.

Worth grepping the same destructure shape across all four projects; it is easy to write and its consequence depends entirely on context.

customer-portal — check every CASL subject name against what is granted ​

The Contacts audit found router/index.ts:295 and AppLayout.vue:140 gating on subject "Contact" while the ability actually granted is "CustomerContact". The names never match, so checkAbility always fails and every non-admin is redirected to /forbidden — the sidebar entry never appears either. Admins are unaffected because of the manage all short-circuit, which is exactly why this survives: it works for whoever tests it.

This is a whole-class defect, not a one-off. The route table declares ~30 abilityCheck subjects as bare strings with no type relationship to the permission catalogue, so any typo or rename produces a silently inaccessible feature that looks fine to an admin.

Recommended as a single cross-cutting issue: diff every abilityCheck.subject in apps/web/src/router/index.ts and every can(...) subject in AppLayout.vue against the subjects the API actually grants, and make the subject a shared typed union so the compiler catches the next rename.

Related and separately confirmed: DATA-ID-BOUNDARY — Contacts and Locations both send the Dataverse externalId where dataverse-commands.repository.prisma.ts:36 looks up the local uuid, so saving an edit 404s every time on both features.