Commit Graph

175 Commits

Author SHA1 Message Date
f023192b74 T10.6 - deployment docs and env var reference describe Okta, not the never-shipped LDAP config
D13 never shipped, so DEPLOYMENT.md, server/.env.example and server/README.md
still described the original local-password system as of this task starting -
POST /api/auth/login, bcrypt password_hash, create-admin with a prompted
password, self-service reset-password email flow, AUTH_RESET_MINUTES /
AUTH_RESET_COOLDOWN_SECONDS. All of that is gone as of T10.4; these three files
now describe what actually runs.

server/.env.example and DEPLOYMENT.md's env block both gain the five OKTA_*
variables (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, IDENTITY_CLAIM),
explained the same way AUTH_SECRET_KEY already was - what it does, where to
get it, what happens if it's missing.

Also updated, not originally named in T10.6's bullet but required for the
documented vars to actually reach a running container: docker-compose.yml's
api service sets environment: as an explicit allowlist, not env_file, so the
four new OKTA_* entries had to be added there too or .env would document
something that silently does nothing. OKTA_IDENTITY_CLAIM specifically is NOT
${OKTA_IDENTITY_CLAIM:-} - compose setting an env var to an empty string is
not the same as leaving it unset, and server/okta_auth.py's own default
(preferred_username) only kicks in when the var is truly unset. Mirrored the
same default in the compose file instead, or every deployment that leaves the
optional line commented out in .env would 503 on every sign-in looking for a
claim literally named "".

server/README.md: replaced the login-portal section with the Okta flow
(access gating is Okta's job, not this app's - roles/authorization stay
local), replaced "create the first admin" with the promote-not-create
bootstrap path (D16) and its no-break-glass posture, replaced the curl-based
login example in Quick Test with a pointer to smoketest.py's own
session-minting technique (there is nothing left to curl - Okta requires a
real browser).

DEPLOYMENT.md: same treatment for its own copies of the env block, the
Portainer var list, the users table's password_hash column, the auth
endpoints summary, the smoke-test walkthrough (WP_SMOKE_USER only, must run
inside the api container or local dev sharing AUTH_SECRET_KEY/DATABASE_URL -
no longer targetable from an arbitrary remote workstation), the entire
"Self-service password reset" section (replaced with "Sign-in and admin
bootstrap (Okta)"), and the project_super_user role description / exclusive-
scope bullet, both of which named "reset passwords" as something that no
longer exists.

Left alone, logged rather than fixed here per CLAUDE.md scope discipline:
- users.failed_attempts / locked_until columns are still in the schema and
  still reset to 0/None on every Okta sign-in, but nothing increments them
  anymore since local login() is gone - vestigial, not documented as active
  lockout behavior in either doc now, but not migrated away either.
- server/README.md's "Production - Docker Compose" section (### 1-5) is a
  self-contained alternate quickstart that already duplicated and diverged
  from the real root docker-compose.yml before this task; it uses env_file
  rather than an explicit allowlist so it isn't broken by this change, but
  it's still a second source of truth nobody asked this task to reconcile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:47:48 -07:00
78d1942a0e T10.7 - add an explicit validation note (22/22, 26/26, 71/71) 2026-09-03 13:34:30 -07:00
45aff9c423 T10.7 - record the real browser-suite verification
url_state_check.py and browser_check.py both need a real headless browser via
tests/cdp.py, which the dev sandbox T10.7 was built in does not have and
cannot install (no sudo, network egress allowlisted against every route tried
- apt, a user-space Playwright Chromium download, Docker Hub). Documented as
an open gap in that commit rather than claimed as done.

Run for real since, on a machine with Docker, through a small general-purpose
tool kept deliberately outside this repo (headless-py-test-runner - it has no
idea what repo it is pointed at, so it does not belong in a Work Package Suite
PR). url_state_check.py 26/26, including scenario 2's actual click-through of
the fake-Okta round trip this task built; browser_check.py 71/71. Gap closed -
wave-10.md now says so instead of leaving the question open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:33:17 -07:00
290c9b078c T10.7 - a fake-OIDC-provider test seam, mirroring ldap_fake.py
Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().

The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.

tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.

tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).

tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.

One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).

wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:11:24 -07:00
72b10283fc T10.5: login becomes an Okta redirect, not a form
server/app.py:
- okta_login(): validates and stashes ?next= (same-site path only) in the
  OAuth-state session before redirecting to Okta, so a deep link an assignment
  email carried (X1/CR-011/CR-014) survives the round trip instead of always
  landing on /index.html.
- okta_callback(): reads that stashed next= back (re-validated on the way out
  too - belt and suspenders against a crafted value) and redirects there on
  success. The two failure paths that used to raise a raw HTTPException -
  OAuthError (sign-in cancelled/failed) and a locally-disabled account - now
  redirect to /login.html?error=... instead: this route is reached by a full
  page browser navigation from Okta, not a fetch() call, so a JSON error body
  just looks like a broken page to whoever is signing in.

html/login.html + html/login.js: rebuilt as a single "Sign in with Okta" link,
replacing the username/password form and the forgot/reset-password views
(gone entirely - no local password exists to reset, per D15/D16/T10.4). Kept
the accessible error/ok banner pattern (role=alert / role=status) byte-for-
byte, since CLAUDE.md names this file as the reference other pages copy for
that pattern. login.js reads ?next= off its own URL (auth-guard.js's
goToLogin() already builds this, unchanged) and forwards it to
/api/auth/okta/login, and shows a plain-language message for ?error=disabled
/ ?error=cancelled, clearing the code from the address bar once shown. Sign-
out (auth-guard.js's wpLogout()) already redirected to login.html - untouched,
already satisfied "lands back on the app's own login page."

Uses a real <a href> rather than a JS-driven navigation, so it's a working
link even before login.js runs, and needs no keyboard/touch handling beyond
what a link gets for free (C1 accessibility).

Also fixed in passing (not a separate commit - this is what exposed it):
_safe_next_path() on the server and safeNext() in login.js enforce the exact
same rule (same-site path only, reject '//' and scheme URLs) so a crafted
?next= can't become an open redirect through a real Okta sign-in.

Verified: a fake-Okta-client round trip against the real app (SessionMiddleware
fix from the prior commit) confirms next= is honored end to end, a malicious
next= is rejected and falls back to /index.html, OAuthError redirects to
?error=cancelled, and a disabled account redirects to ?error=disabled. The
JS-side safeNext() was checked against the same cases directly in Node and
matches the server's validation exactly. login.js passes `node --check`;
login.html parses cleanly. Live 390px/1440px screenshots were NOT captured
this session - the environment's browser pane isn't signed in to view a
published preview of it, so that check needs to happen when this branch is
actually run and opened by a signed-in browser; the layout risk is low since
.card/.brand/.error/.ok/.foot are unchanged from the already-shipped file and
the only new CSS is one simple full-width block link.

wave-10.md T10.5 / D15 / D16
2026-09-03 11:27:26 -07:00
77f8f9f800 Fix T10.2: install SessionMiddleware, required by Authlib
okta_login() and okta_callback() (T10.2) both crash with a 500
(AssertionError: SessionMiddleware must be installed to access
request.session) against a real Okta client, because Authlib's
authorize_redirect() and authorize_access_token() both store/read OIDC
state and nonce in request.session. Never caught by T10.2's or T10.3's
own verification because every prior test mocked authorize_redirect /
authorize_access_token directly, bypassing Authlib's real implementation
entirely. Found while starting T10.5 and reproducing the real flow.

Adds starlette.middleware.sessions.SessionMiddleware, on its own cookie
(wp_oauth_state, distinct from the app's real session cookie wp_session)
with a short 10-minute lifetime and same_site=lax so it survives the
top-level redirect back from Okta. This cookie carries nothing but
ephemeral per-attempt OAuth state — no identity, no long-term secret —
so it reuses auth.SECRET_KEY rather than adding a new required config
knob. Reused in T10.5 to carry the post-login redirect target across
the same round trip.

Adds itsdangerous to requirements.txt — SessionMiddleware's hard
dependency, not previously needed anywhere in this app.

Verified: reproduced the crash against server.app with a fake (network-
bypassed) Authlib client and no SessionMiddleware, confirmed the
AssertionError, then confirmed the same request succeeds (302 to the
authorize URL, wp_oauth_state cookie set) once the middleware is added.

wave-10.md T10.2 (bug fix)
2026-09-03 11:13:28 -07:00
73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00
c74289aa0d D16: Okta admin bootstrap and break-glass posture; correct T10.4 scope
Decision, raised during T10.4 hazard review:

- Admin bootstrap: manage_users.py moves from creating an admin account to
  promoting an existing one, by username, on a row Okta's JIT provisioning
  (T10.3) already created. Rejected blind account creation — the exact
  OKTA_IDENTITY_CLAIM format is still unconfirmed by security, and a
  hand-typed username that doesn't match it produces an orphaned second
  account instead of promoting the real one. Ongoing (non-bootstrap) admin
  naming needs no new work: html/users.js's existing role dropdown already
  handles it.
- Break glass: none, by design, matching the precedent already on record
  for the abandoned LDAPS design (D13/D14) rather than assumed to carry
  over untested. If Okta is unreachable, the app is unreachable for
  everyone until Okta is restored. Rejected a toggleable emergency local
  login — it would reintroduce the stored credential D15 exists to
  eliminate.

Also corrects T10.4's scope in wave-10.md: hazard review found real call
sites of hash_password/verify_password/password_problem the original
bullet didn't name (create_user(), admin_reset_password(), users.js's
admin forms, browser_check.py/launcher_check.py fixtures), plus a
verification-gate ordering problem (smoketest.py and seed_demo.py
authenticate via POST /api/auth/login, which T10.4 removes, and both are
named explicitly in CLAUDE.md's verification section). Fixed by having
T10.4 switch both scripts to mint a session with auth.create_token()
directly, the same technique browser_check.py already uses, rather than
waiting on T10.7.

D16
2026-09-03 10:35:58 -07:00
7ed3cbec4c T10.3: identity matching and JIT provisioning for Okta sign-in
okta_callback() now completes the sign-in instead of stopping at the claim:

- Matches the OKTA_IDENTITY_CLAIM value to a local account via
  auth.find_user() (username or email, case-insensitive) — the same lookup
  login() already uses, so an account whose username mirrors its AD
  identity needs no migration.
- No match: JIT-provisions a new account at the lowest-privilege role
  (project_user), no project membership, no password hash. Access beyond
  that is still granted locally by an admin/project super user, same as
  any account created by hand via create_user(). Logs a user_created audit
  event (via: okta_jit) for parity with that route.
- Match found but is_active is False: blocked with the same 403 'Account
  is disabled' login() raises today. Okta granting the challenge does not
  override an account this app has disabled locally (D15: 'roles stay
  local').
- On success: issues the same session cookie login() does (auth.create_
  token / auth.set_session_cookie), then redirects the browser to
  /index.html — this route is reached by a full-page navigation from
  Okta's redirect, not a fetch call, so a redirect is required rather than
  the JSON body login() returns.

Verified with a fake Okta client against a throwaway SQLite DB: new
identity provisions correctly (role/email/name/no-password), a repeat
sign-in matches the existing row without duplicating it or touching a
role an admin has since changed, a locally-disabled account is blocked
despite a valid Okta claim, and a missing identity claim is rejected
before touching the database.

wave-10.md T10.3 / D15
2026-09-03 10:16:50 -07:00
a9e5ee3892 T10.2: Okta login-redirect and callback routes
Adds GET /api/auth/okta/login (redirect to Okta's authorize endpoint) and
GET /api/auth/okta/callback (exchange code, validate ID token, pull the
identity claim) to server/app.py, using the oauth.okta client from
okta_auth.py (T10.1).

Access gating is Okta's job, not this route's: only accounts assigned to
the app integration in Okta ever reach the callback, so there is no
app-side group/claim check layered on top (D15, wave-10.md T10.2).

Stops at NotImplementedError once the identity claim is in hand. Matching
that claim to a local account and issuing the session cookie is T10.3, kept
separate per the one-task-per-PR rule.

wave-10.md T10.2 / D15
2026-09-03 10:11:49 -07:00
0ee35ae4ed T10.1: Okta OIDC client dependency and config (wave 10) 2026-09-03 09:57:55 -07:00
044862acba Wave 10: task breakdown for the Okta OIDC build 2026-09-02 17:38:35 -07:00
31c548318b D15: retire D13/D14 (LDAPS, never deployed), move straight to Okta OIDC 2026-09-02 17:20:01 -07:00
8f117680b0 Merge branch 'fix/alembic-transaction-per-migration': truthful migration logs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
6034c08bad Alembic runs one transaction PER MIGRATION, not one for the whole chain
The 2026-08-21 crash at material_items printed 'Running upgrade' lines for
location_nodes and wp_files and then rolled all three back together - env.py
wrapped the entire run in a single transaction. The repair that followed
trusted those lines: material_items was hand-created, the version stamped to
head, and production ran for two days missing two tables it claimed to have.
Found 2026-08-23 when the locations import 500'd on UndefinedTable.

transaction_per_migration=True makes the log truthful: a crash keeps every
step that completed, and a stamp-to-head repair after a crash repairs ONE
migration, not an unknowable prefix of the chain. Verified: the full chain
still applies on a fresh scratch SQLite; the offline --sql render is
unchanged.

The production surgery (creating the two rolled-back tables from the offline
postgres render) is recorded in the session; no version stamp is needed
there - it is already, now truthfully, at head.

Items: BL-027's class, third finding; env.py infrastructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
17cabbd032 Merge branch 'fix/import-row-hazards': imports reject rows, never 500
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:38 -07:00
222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
Nick's real location list hit the production import and got 'Internal Server
Error' with no line number - BL-027's class again, three days after the
migration outage: Postgres enforces VARCHAR lengths and refuses control
bytes, SQLite shrugs at both, and the importers were only ever rehearsed on
SQLite. Reproduced both hazards locally (an over-long value and a NUL byte
import cleanly on SQLite; either 500s Postgres wholesale).

Both importers now validate per row, before any INSERT, so every dialect
answers the same way - with the line number and a reason:
- locations: control characters; names over 200; codes over 60; combined
  paths over 200 (checked where the path exists, with read-counts taken
  before the loop so a mid-loop rejection is not counted twice)
- materials: control characters; description/unit/code over 300/20/80

And the client stops lying about it: wp-list-import.js read every response
with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not
reach the server' while the server was answering fine. One tolerant reader
(text -> parse if it parses -> keep status) now serves import, add and patch;
a real error reads 'Import refused - HTTP 500'.

Pins: materials_check +2 (over-long and control-byte rows reject at line,
20/20), locations_check +1 (over-long name rejects at line, 59/59).

Items: CR-005, D6, BL-027 (second instance of its class; the probe-side
dialect guard it proposes is still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:37 -07:00
e31234beef Merge branch 'docs/bl-026-027': the outage's two lessons, backlogged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:32 -07:00
6057d05b98 BL-026 / BL-027 - the two lessons of the Aug 21 outage, logged
BL-026: no version stamp - 'is live current?' took mid-outage fingerprinting;
the fix shape is a git SHA baked at build, served by /api/health, shown on
the admin diagnostics card (D13 candidate).

BL-027: migrations rehearsed on SQLite only - the dialect drift class behind
the outage stays unguarded beyond the one pinned instance; the fix shape is
an offline postgresql-dialect render in the runbook and/or a probe.

Items: backlog only, no code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:30 -07:00
64eac0cbbb Merge branch 'fix/material-items-boolean-default': the Postgres deploy hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
8f280d4bd1 D6 hotfix - the material_items migration crashed Postgres at deploy
server_default=sa.text('1') on a Boolean: SQLite coerces integer 1, Postgres
refuses it (DatatypeMismatch: column 'active' is of type boolean but default
expression is of type integer) - so 'verified end-to-end on a scratch DB' was
true and insufficient, because the scratch DB was SQLite. Found in production
2026-08-21: the wp.controls.dev api container crash-looped on alembic upgrade
and the site served static pages with a 502 API until the table was created
by hand from the db container (identical DDL, alembic_version stamped to
a1b8c6d4e2f9, so this fixed migration is a no-op there).

Now sa.true() - which the location-taxonomy migration next door used
correctly all along, and which is why IT applied to production without
incident. materials_check gains the static pin: every Boolean server_default
in every migration must be sa.true()/sa.false(). Verified: alembic --sql
offline render for the postgresql dialect emits DEFAULT true; the full chain
still applies on a scratch SQLite.

Items: D6 (the migration), CR-013 surface. Probe: materials_check +1 static
check (its browser half was env-blocked today - headless browser would not
start; the fix is exercised entirely by the static half and the two renders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
a8e28bf3ab Merge branch 'feat/wp-suite-r3-housekeeping': the Aug 20 decisions, built
Nick's six answers (decisions-2026-08-20.md, evening section) plus the
approved housekeeping, one commit per item:

- F6 strict 2.0: the creator fits two screens at rest (1,954 -> 1,784px);
  form_structure_check 51/51 and the suite has ZERO red checks for the
  first time. Closes BL-022.
- Hold reachable from any status: recorded as-is, question closed.
- CR-014: bodies carry customer context (number - title, location, deep
  link), never document content; canary pins split to match the rule.
- CR-008 merged-PDF: KNOWN-ISSUES 3, decided not deferred.
- D12: the productivity factor (act/est) on the dashboard, server sums.
- BL-020 closed (keep the prompt). BL-021 fixed: the critical-reopen mail
  reaches the PM and CM at last (critical_reopen_check, 11, sink-verified).
- BL-024: the last 21 native dialogs onto the shared wp-dialog.js kit;
  app-wide native count is now zero (console_dialogs_check, 17).
- BL-025: the final second-brand-blue tint rebased; check widened.
- S13: already fixed at T1.6 - stale records corrected, incl. CLAUDE.md.
- CR-011 transport: EHLO pinned; DNS trouble was stalling every send ~5s.

Battery: 16 suites re-run, all green, no deliberate exceptions remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:28:09 -07:00
29c4cd313e S13 - already fixed at T1.6; the records said otherwise, now corrected
The housekeeping list carried S13 ('seed_demo.py does not sign in') from
completion.md and CLAUDE.md. It is not true and has not been since wave 1:
T1.6 (357712e) rewired seed_demo.py onto smoketest's opener - one cookie jar,
one login flow - and the file's own docstring says so. What actually happened:
the wave-1 exit checkbox was never ticked, and every later document inherited
the unticked box as fact.

Verified live before correcting anything, per the working rules: against a
throwaway server, seed_demo.py signs in as an admin, seeds the DEMO project
(7+ packages visible via the API), and --clean removes it, exit 0 both ways.

Corrected: the wave-1 exit box (ticked, with the reason), completion.md's S13
row (open -> built at T1.6, records error named), and CLAUDE.md's
verification step 4, which taught every future session the stale claim.

Item: S13 (closed as already-built; records corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:26:40 -07:00
8fe7b25cd8 BL-025 - the last tint of the second brand blue, and the grep that missed it
help.js's search-focus ring was rgba(37,99,214,.15) - the banned #2563d6 as a
space-free rgb triple, which slid past color_check's spaced grep ('37, 99,
214') from the day BL-008 removed the colour. C4's recorded exception
legitimately allows rgba ALPHAS as opacity recipes; the defect was the base
colour under the alpha. Rebased onto THE blue: rgba(15,98,254,.15).

color_check compares space-free and case-insensitive now, in both the theme
check and the consumer sweep, so no spelling of the dead blue can return.

Items: BL-025 (closed), C4, BL-008 lineage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:43 -07:00
560f0cb3cc BL-024 - the last 21 native dialogs, onto the shared kit
S1 counted 79 native dialogs app-wide and its tasks removed 58; the audit
found the rest on surfaces no S1 task named: admin.js (6), users.js (10), the
launcher's inline script (5). All 21 now go through wp-dialog.js - the T7.9
kit extracted as a self-injecting shared component: markup and styles land on
first use, styles are theme tokens only with its own wp-dlg-* class names (the
consoles' existing .modal styles are untouched), 44px targets on coarse
pointers, and the whole file is guarded so the creator's inline copy - which
owns the same-id markup in its HTML - still wins on its own page. The kit's
toast comes along (S10 role rules), since none of the three pages had one.

Conversion follows the T7.9 precedent: confirms -> wpConfirmDialog with named
ok-labels, the password prompt -> wpPromptDialog whose validate() finally
enforces min-12 AT the input (it was label-text-only before, server-enforced),
API failures with detail -> wpAlertDialog, small info/validation messages ->
the announced toast.

New probe console_dialogs_check (17): counts pinned at 0, kit guarded and
loaded by all three pages, and the users console driven live with natives
poisoned - reset a password end to end (short refused inline, good one accepted
by the server and announced), cancel a delete and prove nothing died.

Items: BL-024 (closed), S1 completed to zero app-wide, C1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:09 -07:00
24f60151e5 BL-021 - the critical-reopen mail reaches the PM and CM, at last
project_sop_team() read sop.data['project']; pushSOP stores every row as
data={sop, state}, so the project block is one level deeper. The lookup
returned [] for every real row, silently, and the on-hold email promised to
'Owner + PM + CM + distribution' has reached only owner + distribution since
the day it shipped. One line: the same nested-first tolerant read
project_qa_group has used all along (whose docstring logged this very bug).

New probe critical_reopen_check (11): the fixture writes the PRODUCTION shape
- a hand-built flat row would have passed against the bug, which is exactly
how it went unverified this long. Sink-verified end to end: assignee + PM +
CM and nobody else; constraint name, title, location, deep link and the house
footer in the body (the footer this body alone used to lack, fixed at CR-014).

Items: BL-021 (closed), CR-011 recipients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:17:31 -07:00
031dc6b995 D12 - the productivity factor, on the dashboard (was BL-023)
Nick's decision: 'find a spot on the dashboard.' The spot: an eighth metric
card beside Est./Actual hrs - actual/estimated to two decimals, green at or
under 1.0, red over. Both hour fields are optional (CR-017), so with nothing
to divide the card shows an em dash rather than vanishing: a metric that
disappears reads as 'no such measure', not 'nothing logged yet'. Server sums
(B4), the same m.est_hours/actual_hours its neighbours already render - zero
new fetches, and the card stays inside the block the metrics-failure path
skips, so an outage still shows the error panel and no cards.

aggregates_check gains the pin (16 -> 17): the card must equal the quotient
of the SERVER's sums, or the em dash when either sum is zero - derived, not
hardcoded. Backlog entry corrected in passing where it credited
/api/projects/{id}/summary with hour sums it never carried.

Items: D12 (decisions-2026-08-20.md), CR-017 read, B4 discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:14:15 -07:00
0f28a27441 CR-014 - bodies carry customer context and the link carries the content
Nick's decision, 2026-08-20: 'email bodies provide links back to the system.
we can talk about customers we just cant exposed their confidential
documents.' The T7.6-era rule (no customer IP at all, so number + link only)
is refined: context IN, content OUT.

- wp_titled() and wp_where() compose 'number - title' and the CR-004
  location (structured paths first, legacy free text second); the where-line
  is dropped entirely when unset rather than mailing 'Where: '.
- assign, qa-ready, qa-reject and hold bodies gain title + location. The
  scope summary the original CR asked for stays OUT - scope text is document
  content; the link is its summary. Rejection comments stay on the package.
- hold_body gains the house footer it alone lacked.
- kitting and material-request bodies adopt wp_titled for the same identity
  line (their delivery-location rule is unchanged).
- notify.py's docstring states the new rule where the transport documents it.

Pins flipped WITH the rule, reasons in code: qa_gate_check's location canary
is now asserted PRESENT in QA bodies; a new DESC_CANARY (document content) is
asserted absent from every message (40 -> 41 checks). The sink also gains a
decoded-body view: the em-dash switches smtplib to quoted-printable, whose
column-76 soft breaks made raw-payload substring pins pass or fail on luck of
line position - content pins now read the decoded body, header pins still
read the wire payload.

Battery: qa_gate_check 41/41, kitting_notify_check 17/17, mreq_check 19/19.

Items: CR-014 (rule per decisions-2026-08-20.md), CR-011 pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:11:37 -07:00
16afc56c0a CR-011 transport - pin the EHLO name; DNS trouble was stalling every send 5s
smtplib calls getfqdn() on every connect when local_hostname is not given, and
that reverse-DNS lookup blocks ~5s per send whenever DNS is slow or down (found
when the office link dropped today: qa_gate_check's sink saw one mail per ~5s
and its 12s waits timed out). Sends are sequential background tasks, so the
stall compounded across a notification batch - in production a QA transition
with a 3-person group would take 15+ seconds to finish mailing.

socket.gethostname() never touches the network; the EHLO name is now computed
once. Measured against the capture sink: 5.3s -> 0.3s for a two-recipient
batch. Server mail path otherwise untouched.

Item: CR-011 (the send path's transport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:05:43 -07:00
8efe624d5d F6 - strict 2.0: the creator fits two screens at rest (closes BL-022)
Nick's answer: 'strict 2.0'. The 154px overage was chrome, and every trim
densifies rather than deletes - A2's one-warning banner and the SOP identity
strip both stay:
- collapsed section rows 46 -> 36px on fine pointers (13 rows at rest was
  ~130px of the overage); coarse pointers keep the 44px tablet row (C1)
- ctx-bar 12 -> 7px padding; banner margin 14 -> 8, padding 11 -> 8
- .main top pad 22 -> 14 (bottom stays clear of the sticky bar)
- nav-row 24/24 -> 14/14

Measured at 1440x900: 1,954 -> 1,784px = 1.98 screens. form_structure_check
is 51/51 for the first time - the check never moved, the page now fits it.
mobile_check 24/24 (the coarse-pointer targets held).

Items: F6, BL-022 (closed), C1 preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:52:28 -07:00
24476c86a6 Decisions of 2026-08-20 (evening), recorded
Six answers from Nick: F6 is a strict 2.0 screens (build task, chrome
compresses); hold stays reachable from any status (T7.3 question closed);
CR-014 bodies get deep links and may name customer context but never embed
confidential document content; CR-008 merged-PDF becomes KNOWN-ISSUES 3
(decided, not deferred by accident); BL-023 becomes D12, the productivity
factor on the dashboard; BL-020 closed as decided-keep. Housekeeping
(BL-021, BL-024, BL-025, S13) approved to build on this branch.

Items: F6, CR-008, CR-014, D12, BL-020, BL-022, BL-023.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:45:58 -07:00
cc761c8f7d Merge branch 'feat/wp-suite-r2-implementation': the R2 plan, complete
All 66 items: the 55 of IMPLEMENTATION.md section 6, D1-D10 (decisions
2026-08-18), and D11 (the Micron asset picker from origin/Micron-Assets,
merged and adapted 2026-08-20). Nine waves, one task per commit, reconciled
item by item in docs/reference/completion.md.

Also carried: the C4 transparency regression fix (undefined token names),
the S8 glossary-class leak fix (the always-lit Issue pill), and the seven
findings of the D11 adversarial integration review.

Verification: ~31 self-contained probe suites in tests/ (~950 checks), run
one at a time; all green except form_structure_check's deliberate BL-022 red
(F6's 'roughly two screens' = 2.17, held open for a product answer).

Open items for the next revision are listed in docs/waves/backlog.md
(BL-020..BL-025, S13) and completion.md section 'For the next revision'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:34:17 -07:00
fb89b1f6e1 S8 fix - help centre glossary classes leaked onto the Issue (hold) status pill
help.js injects its stylesheet on every page, and its glossary pills used bare
class selectors (.pill-draft ... .pill-hold). The creator's Issue (hold) status
radio also carries the class pill-hold, so the injected rule painted that radio
error-red at ALL times - selected or not. Reported by Nick ('why is the issues
(hold) button illuminated at all times'), 2026-08-20.

Pre-existing, not from this branch: help.js has had the bare selectors since
the login-portal commit, and the creator's pill-hold class predates the R2
branch. Every glossary rule is now scoped to .ui-help-pill.pill-*, which the
glossary markup already carries. Verified live: unselected, the hold pill's
computed style now matches its neighbours exactly; selected, it is still the
red fill; the glossary's own Hold pill keeps its tint. helptip_check gains the
pin (13 -> 14): no bare .pill-* selector in help.js, ever again.

Item: S8 (the help component's app-wide surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:59:30 -07:00
8663d81af3 C4/D11 follow-up - the integration review's seven confirmed findings
An adversarial review (four lenses, every finding independently verified by two
skeptics told to refute it) ran over 2a5f6b3 and 8cf8c0f. Seven findings
survived; all seven are fixed here.

Against the C4 fix:
- help.js: the nav hover was renamed onto its own surface token, keeping a
  no-op T9.9 had introduced (two different grays had been mapped to one name).
  Hover is now --cds-layer-hover, the token that exists for exactly this.
- wp-creation-app.js: the drawer's critical CSS pre-painted --cds-layer-accent
  while the stylesheet paints --wp-nav-bg; now both paint --wp-nav-bg.

Against D11:
- wp-sections.js: the Assets toggle note still described the pre-D11 card
  ('Asset tags and controls.dev links') with a rationale the picker inverts.
- runAssetSearch: the result cap counted contains-matches before the exact and
  prefix tiers finished, so 500 alphabetically-early substring hits could evict
  the exact match - and Enter then added the wrong asset, ID-locked. The cap
  now bounds each tier; the scan always sees the whole catalog.
- addCatalogAsset: the one mutation in the section with no announced outcome
  was the successful pick. It now toasts (role=status), matching every sibling
  path (C1).
- assets_db.py: failures are remembered for FAIL_CACHE_SECONDS (default 30s)
  and a stale catalog is served over an error, so a Micron outage costs one
  CONNECT_TIMEOUT per window instead of one per page load stacking up in the
  shared sync threadpool until login itself stalls.
- assets_db.py: MICRON_ASSETS_CACHE_SECONDS='5m' no longer crashes the boot -
  a malformed knob on an OPTIONAL feature degrades to its default, loudly.

assets_check grows four regressions for these (27 -> 31): per-tier cap against
600 decoys, the announced pick, boot with a malformed knob, and the stable
cached 503. Battery: assets_check 31/31, color_check 5/5, sections_check ALL
PASS.

Items: C4, D11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 12:09:40 -07:00
8cf8c0f882 D11 - merge origin/Micron-Assets: the Micron asset picker, adapted to R2
Integrates Cody Schaefer's 7ef1fcd (written against pre-R2 main) per Nick's
instruction of Aug 20. The catalog lookup arrives whole: read-only /api/assets
backed by server/assets_db.py (one SELECT, env-only MICRON_DB_URL, 503-not-500
when broken, driver errors logged not propagated), the searchable picker with
CSV import and Excel column paste, catalog rows badged and locked to the DB's
casing, manual rows visibly unvouched, and graceful absent/unreachable states.

Three conflicts, resolved as unions of both sides' intent; the adaptations and
their reasons are recorded in docs/waves/decisions-2026-08-20.md:
- renderPackage: Cody's two-column asset table inside T9.1's sectioned
  add('assets', ...) frame, so the CR-006 toggle keeps governing the export.
- bootData: initAssetPicker() joins the R2 loads instead of replacing them.
- The asset card: his picker UI, plus role=status on the source note (C1).
- Six imported alert() calls converted to the creator's idioms: file errors
  through toast(msg,'alert') as the drawings uploader does; the instructional
  and summary messages through the T7.9 kit, which gains the one-button
  wpAlertDialog shape (BL-024's console conversions will want it too).

New probe: assets_check (27) - read-only structurally, unconfigured/broken as
first-class states, no credential echo, search ranking, casing canonicalisation,
import fallback + dedup, kit-not-native summary. One sections_check pin
re-pointed with the reason in code: normaliseAsset now stamps legacy rows
source:'manual' on load, so the CR-016 check compares content, not bytes.

Battery after merge: assets_check 27/27, creator_dialogs_check 20/20,
sections_check ALL PASS, export_check 20/20, helptip_check 13/13,
mobile_check 24/24, icon_check 5/5, color_check 5/5, form_structure_check
50/51 (the one red is BL-022, unchanged, deliberate).

Item: D11 (new scope, new id per the working rules). Out-of-scope note in
completion.md amended - 'no integration code exists' was true when written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:45:32 -07:00
2a5f6b3549 C4 fix - five undefined token names rendered surfaces transparent
The T9.9 token sweep pointed seven files (help.js, auth-guard.js, wp-format.js,
project-data.js, index.html, field.html, wp-creation-app.js) at Carbon names
the theme never defined: --cds-layer-01/-02, --cds-border-subtle-01/-strong-01,
--cds-layer-hover-01. theme-light.css carries no -01 suffixes. An undefined
var() invalidates the whole declaration, so the help-centre modal, the
change-password and language dialogs, the print popup's inlined values, the
creator nav drawer and the sync badge all rendered TRANSPARENT backgrounds -
reported by Nick against the help menu, 2026-08-20.

Renamed every consumer to the canonical tokens (--cds-layer, --cds-layer-accent,
--cds-layer-hover, --cds-border-subtle, --cds-border-strong), matched to the
hex each replacement originally stood in for. color_check gains check 3: every
var() consumed anywhere must resolve to a definition somewhere - the class of
this bug, pinned. Verified live: the modal computes rgb(255,255,255) over an
opaque gray nav, and the language dialog is opaque too. BL-025 logged for the
one wrong-base-colour rgba tint noticed in passing.

Item: C4 (regression in its own enforcement). Probe: color_check 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:27:12 -07:00
454bfa0fe1 T9.7 + wave 9 exit - all 65 items, reconciled
docs/reference/completion.md walks every item: the 55 from IMPLEMENTATION.md
section 6 and the 10 from decisions-2026-08-18.md. For each: status, the task
that delivered it, the probe that re-verifies it on every run, and every
deviation from written acceptance criteria - B7's page-not-merge (measured),
CR-014's email body (the no-customer-IP rule won), CR-008's merge-vs-list
(recommended, not decided), F6's 2.17-vs-2.0 (BL-022), S1's residual 21
dialogs (BL-024).

The four out-of-scope items are confirmed unbuilt - two of them by probes
that grep for their fields on every run. Section 8's outstanding inputs are
restated (the material workbook and the B100 list still have not arrived;
both upload paths are ready). The follow-ups for the next revision are in one
place, including three product questions raised in commit messages along the
way and the acceptance criteria that turned out wrong, for calibration.

One item in the whole plan is knowingly open: S13 (seed_demo sign-in),
carried with a reason, and F6's last number awaits a product answer.

Wave 9 exit criteria: seven of seven, ticked with their verifying probes.

Items: all

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:29:29 -07:00
23ee0b052f T9.9 - C4 + the backlog sweep: nine entries closed, each re-measured first
The colour half (C4, approved Aug 18 - "change them"):
- BL-004: the help centre's own 52-colour palette collapsed onto theme tokens
- BL-005: the JS-built dialogs (auth-guard, wp-format) and project-data's
  badges read tokens; the creator's categorical badge palette moved to
  theme-light as --wp-chart-1..10, read by computed style at boot; the print
  popup - a document with no stylesheet - inlines live token VALUES
- BL-008: the second brand blue (#2563d6) is deleted; .sop-inherited tints
  with THE blue at the same 7% alpha
- BL-009: the ninth amber (--wp-status-warning-text-alt) is deleted
- theme-light gained the two missing feedback tokens the consoles carried as
  literals (--wp-status-success-text / -error-text)
- NEW tests/color_check.py 4/4: zero hex literals outside theme-light.css,
  comments stripped (the BL-017 lesson), with the exceptions named in full
  (meta theme-color cannot resolve a var; rgba alphas are opacity recipes)

The correctness half, each re-measured before touching, as the task ordered:
- BL-011 STILL REPRODUCED: the sync badge mounted on the first async sync
  event; its holder now mounts at DOMContentLoaded, so the three overlays land
  in script order deterministically
- BL-012 fixed and MEASURED: baseline_shots freezes Date and Math.random per
  document; two consecutive admin captures came back byte-identical
- BL-016 fixed: a step-less wizard URL is step 1; stepper_check's deliberately
  wrong pin flipped with the fix, exactly as the entry planned
- BL-018 fixed both halves: the false-complete write now requires the
  {sop,state} production shape, and browser_check.seed writes that shape -
  which un-detoured four probes' creators from the SOP gate. stepper_check
  re-pointed at projB (no SOP) because its premise is a wizard someone is
  STARTING, and projA now legitimately restores a finished one.
- BL-019 fixed: a stored cost code that left COST_CODES is kept as an option
  (the gov_wosize pattern), so opening a package no longer blanks its record
- hold_check's AST sweep refined in passing detection: it flagged T8.3's
  notification-row .status as a release transition; it now reads wp.status only

Every wave-9-pointing backlog entry is closed with its measurement recorded.

Verification (each probe run alone): color_check 4/4, stepper_check 71/71,
validation_check 77/77, url_state_check 23/23, autosave_check 34/34,
a11y_check 22/22, launcher_check 58/58, aggregates_check 16/16,
kitting_check 26/26, hold_check 50/50, mobile_check 24/24, frame_check 38/38,
sections_check 95/95, form_structure_check 50/51 (BL-022's question).

Items: C4, BL-004, BL-005, BL-008, BL-009, BL-011, BL-012, BL-016, BL-018, BL-019

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:27:26 -07:00
771672273d T9.8 - D7: archiving stops reading as deletion - for project admins
Archiving already froze a project (the server refuses every write); what did
not exist was the way back in. Now:

- GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE:
  a project admin (or super/app admin) on THAT project sees it; everyone else
  receives an empty list from the same request - archived projects appear
  nowhere for them, counts and pickers included (the default listing already
  excluded them for everyone; asking is what got gated). Admin-on-Job-A does
  not surface archived Job B.
- The launcher gains a visibly separate, labelled "Archived projects"
  section (dashed border, read-only stated in words), rendered only when the
  server returns rows. Opening one makes it active; the launcher's reconcile
  learned that an active project whose stored summary says archived:true was
  opened ON PURPOSE and keeps it, while a project archived out from under
  someone still drops with the existing explanation.
- The creator shows ARCHIVED - READ-ONLY where the project is named (both
  ctx-bar branches, from the SERVER's answer - the page's project comes from
  the URL, so a stale local summary is not trusted) and refuses saves with a
  reason before the round trip. The courtesy; the server's refusal is the
  rule, verified by calling the endpoints directly (wp upsert AND the
  material-list write both refuse with "archived" even for an admin).
- No unarchive button, no second mechanism, and it fits at 390px.

Verification (each probe run alone): NEW tests/archived_check.py 15/15.
Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20,
frame_check 38/38.

Items: D7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:58:51 -07:00
6201fcfb4a T9.6 - C2: the 390px pass, measured on all seven pages
The deliberate mobile pass the original proposal never had. Driven page by
page at 390px with mobile emulation (the media queries under test actually
fire) by NEW tests/mobile_check.py, 24/24:

- no page scrolls sideways - all seven (the creator joined at T9.5 when
  BL-001 died)
- no visible control is clipped past the viewport (the probe learned
  frame_check's two lessons: an off-canvas drawer is PARKED, not clipped,
  and a row inside an overflow-x container is scrollable)
- tap targets: the shared coarse-pointer block in wp-chrome.css puts every
  button, input, select, nav link and appbar control at a 44px minimum on
  phone widths and coarse pointers; checkboxes, radios and help-tip badges
  get the 24px WCAG floor with spacing doing the rest. Field View - the
  gloved-hands surface - measures 44px on EVERY control. Inline text links
  are exempt per WCAG 2.5.8's own exception. Even the deliberately
  unobtrusive dev toggle grew to the floor: subtle by opacity, not by size.
- CR-007 attachments offline at 390px and T8.5 requests at 390px were already
  pinned by files_check and mreq_check; this pass cites rather than repeats.

After-screenshots for all seven pages at 390px are committed in
docs/reference/baseline/after-wave9, beside the wave 0 set, captured by the
same baseline_shots.py fixture.

Verification (each probe run alone): NEW tests/mobile_check.py 24/24.
Regressions: form_structure_check 50/51 (BL-022's standing question),
files_check 36/36.

Items: C2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:47:17 -07:00
0dcea8d725 T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:37:10 -07:00
b83f2fd8d5 T9.3 - S6: one icon system - monochrome text glyphs, one meaning each
The set mixed colour emoji with dingbats, and the same glyph read as two
things partly BECAUSE emoji render as per-platform artwork. The system chosen:
monochrome text-presentation glyphs - the suite is classic-script vanilla HTML
with no bundler, so an SVG sprite or icon font is a new asset pipeline, while
text glyphs render through the same font stack as the words beside them. The
enforceable form of "renders identically on Windows, macOS and a tablet":
no emoji-range codepoint and no U+FE0F selector anywhere in UI source,
swept by the probe on every run.

Converted: green-check/red-cross emoji in the admin and users consoles to
checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the
pencil already meaning "edit with a logged reason" on sign-offs, the star to
the diamond, the folder to the reference marker, the side nav's lightning to
the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per
Unicode) to a text-presentation clock face. Dropped where the label already
carried the meaning: lightning on Save & view, the camera on Add photo, the
page/frame pictograms on file rows (the filename is the label). Stale help
copy fixed while its emoji left: it still described the pre-T9.4 "Load
sample" and the pre-T7.10 "Usage Logs" locations.

The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per
glyph, one glyph per meaning, both directions asserted from the document
itself; the probe also sweeps every page for glyphs not in the approved set,
so an unmapped icon cannot creep in.

Verification (each probe run alone): NEW tests/icon_check.py 5/5.
Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22,
cards_check 44/44.

Items: S6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:15:59 -07:00
b44afa7672 T9.4 - S7: one sample-data affordance, confirmed, and fenced off the project
Four affordances under three names became ONE: "Load sample data", on the
creator's toolbar, at the far end of two separators from the live actions
(New / Duplicate), pushed right with its own gap. It confirms through the
T7.9 dialog, naming exactly what it does - and what it does not: "This page
only: nothing is written to the project unless you then save." The probe
verifies the fence the way the done-when demands - against a REAL project,
reading the server's SOP and work-package list before and after and asserting
byte-identical.

Gone: the wizard's header "Load sample" (the dangerous one: it filled the
state completeSOP() pushes to the LIVE project, one click, no confirm, no
undo - reconciled with D1 exactly as the task records: the creator's control
is the survivor, the wizard copy goes), the creator's split Sample SOP /
Load example pair (now internals behind the one entry point), and the
empty-state context bar's third button (its text now points at the toolbar
control). The location/material "Load sample values" buttons stay: they fill
a PASTE BOX that acts only through an explicit, dry-runnable import - a
different thing, stated in the code.

Probes re-pointed with reasons in place: frame_check's D1 toolbar list names
the consolidated control; validation_check's sample-driven toast checks
became the-affordance-is-gone checks (and its stale showAnalytics drive,
orphaned by T7.10, became a the-duplicate-stays-gone check).

Verification (each probe run alone): NEW tests/sample_check.py 10/10.
Regressions: validation_check 77/77, frame_check 38/38, kitting_check 26/26,
export_check 20/20, sections_check 95/95.

Items: S7 (D1 reconciliation honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:05:56 -07:00
c2a1cc7c26 T9.2 - CR-017: Actual Hours is still there, still optional, still counted
A guard, not a build. Removal was floated in the meeting and rejected -
Marlena tracks actual hours so they can be measured - and CLAUDE.md carries
that as a recorded decision. Verified after eight waves of change:

- Actual Hours exists in Closeout (wp_actual_hrs), persists through collect,
  and prints on the export
- it is OPTIONAL: a package closes with it empty (driven, not assumed)
- it rolls up per T6.4: rollup_check has pinned actual-hours aggregation at
  every level since wave 6, and /api/wps/metrics carries actual_hours in its
  buckets

The follow-up the done-when requires is logged as BL-023: a productivity
factor (actual / estimated) - the rollup endpoints already carry both sums,
so it is a presentation task awaiting its own item id and a placement call.

Verification: export_check.py extended to 20/20 (the CR-017 section).

Items: CR-017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:53:00 -07:00
eb1497d574 T9.1 - CR-008: the export, finally walked through
Every required field verified present under the Micron configuration: P6
Activity id and description, Priority, the Building/Floor/Sector location
row, Scope & Work, Material List, Constraints with their status carried by
WORDS (a black-and-white print keeps its meaning), Quality & Hold Points, and
the Drawings & Attachments index with per-file descriptions (T7.7's columns).

What must be absent, absent: the Micron samples now carry
fields:{costCode:false, acumaticaTask:false} - CR-002's two removals,
expressed as the toggles CLAUDE.md requires, in the creator sample AND the
wizard sample - so ACU Cost Code and Acumatica Task appear nowhere on the
Micron export while the columns, model and recorded values stay. CR-006-
suppressed sections (assets, kitting) are absent, as pinned since T5.7.

Tablet legibility, three real defects fixed:
- a bare `table { min-width:520px }` in the narrow-screen media block reached
  the EXPORT tables too, dragging the whole document to 520px on a 390px
  screen; scoped to .table-wrap (the form's scroll containers), because the
  export must FIT a tablet, not scroll
- export tables now table-layout:fixed with overflow-wrap:anywhere
- at <=768px the doc sheds its 52/56px desk padding and neutralises the
  inline column widths (the one legitimate !important: outranking an inline
  style is its job)

RAISED, NOT DECIDED (the task says propose, do not assume) - merge versus
list for attachments: RECOMMEND MERGING image attachments into the printed
document (already done - they print inline as the sheet itself) and LISTING
PDFs as named, described links rather than merging them. Merging PDFs
server-side needs a PDF library dependency and re-renders every export for a
need the meeting expressed as "hand someone exactly the sheet" - which the
5MB single-sheet uploads plus inline images already serve. If merged-PDF
output is wanted anyway, it is a bounded server task - needs Nick.

Verification (each probe run alone): NEW tests/export_check.py 17/17.
Regressions: form_structure_check 50/51 (the standing F6 height question,
BL-022), sections_check 95/95.

Items: CR-008 (CR-002 field toggles applied to the samples)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:51:44 -07:00
bf28489954 Wave 8 exit - kitting has structure, and material moves on the record
Six of six exit criteria verified and ticked. Wave totals: 6 tasks
(T8.1-T8.6), 3 new probe suites + 1 extended (kitting 26, kitting_notify 17,
materials 17, mreq 19 - 79 new checks), one Alembic migration
(material_items), one shared component extracted (wp-list-import.js), no real
email sent anywhere on this branch.

Items: CR-009, CR-010, CR-011, CR-012, CR-013, D6, D10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:34:02 -07:00
898e5dab94 T8.5 - CR-013/D6: the material request is structure, not features
The OneNote comparison from the meeting was "word vomit"; the structure that
replaces it, built at the lightweight scope EXACTLY as approved Aug 14:

- Line items (qty, unit, description) added, edited, removed. Descriptions
  offer the D6 project list through a datalist - which is also precisely what
  keeps free text working when no list is loaded, the state every project is
  in today. Picking a listed material fills its unit; nothing locks.
- Needed-by date, requestor (the signed-in account), delivery location (T8.4's
  fields on this package, composed), and an explicit status set
  (Requested / Filled / Declined). The request rides on the package record
  (data.materialRequests) - server-persisted through the same upsert as
  everything else, never localStorage.
- Submitting notifies the warehouse owner named on the package (CR-010) - the
  routing that replaces the funnel through one person - through the T7.6 gate,
  with the count, the needed-by, the delivery location and the deep link, in
  the house convention. material_requested lands in the audit history.
- The dashboard grows a Material requests queue, filterable by status and by
  delivery location.
- The block lives inside #material-card, so the CR-006 materials toggle
  governs it with no special casing. The whole flow is driven at 390px -
  requests originate in the field.
- NO parts catalog, no inventory count, no warehouse integration - the probe
  greps the block for them.

One infrastructure bug fixed in passing detection (not silently): T8.5's
dashboard-panel insert matched the substring inside "async function
dashIssue", splitting the async keyword from its function - the creator
failed to parse and every boot died. Caught by the probe's first run;
anchored fixes now restore both halves.

Verification (each probe run alone): NEW tests/mreq_check.py 19/19 (request
end-to-end at 390px against the SMTP sink, dashboard filters, fences).
Regressions: frame_check 39/39, sections_check 95/95, kitting_check 26/26.

Items: CR-013, D6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:33:34 -07:00
190144c539 T8.6 - D6: the material list uploads the way the location list does
CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.

THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.

The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).

Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.

Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.

Items: D6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:24:21 -07:00
b9d5f8ef92 T8.4 - CR-012: the delivery location is the shared vocabulary plus fifty feet
Staging is not the pain; the last fifty feet are - the correct floor lay-down,
shark cage or conduit tree instead of material picked at will by whoever is
closest. The Kitting & MIMO section gains:

- Delivery Building / Floor / Sector: the SAME dependent pickers CR-004 built,
  through the same fillLocSelect (which learned an optional field-map instead
  of being copied), reading the same project location lists, storing PATHS.
  A parallel free-text location vocabulary is exactly what CR-004 removed;
  none was added.
- A free-text detail field for the specifics ("Shark cage 7, conduit tree C"),
  persisted as delivDetail.
- deliveryLoc, the composed display string (labels off the shared lists, then
  the detail after a dash) - which is what the CR-011 email already reads
  (kitting_body preferred deliveryLoc from day one, with mimoLoc as the
  pre-CR-012 fallback) and what the package printout now carries as its own
  Delivery Location row.

Verification (each probe run alone): kitting_check.py extended to 26/26 (the
delivery selects are asserted to offer the SAME option list as the CR-004
trio, values persist as paths, the printout carries the composed value);
kitting_notify_check 17/17 now asserting the mail carries CR-012's composed
value, not the fallback. Regression: locations_check 58/58.

Items: CR-012

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:11:05 -07:00
829fa22236 T8.3 - CR-011: material moves, the field hears about it once
A kitting status change (detected on the upsert, which is how the browser and
the offline outbox both save) emails the package's distribution list
(distributionIds) plus its warehouse owner (kitOwnerId - CR-010's default
recipient), minus the actor, deduplicated. The mail matches the house
convention - greeting, one line of what happened, the deep link, the
automated-message footer - and says old status, new status, who, and the
delivery location (deliveryLoc when CR-012 lands at T8.4; mimoLoc today).
The link opens THAT package (X1), same wp_link as every other mail.

No burst: an unsent notification for the same package and recipient is
REWRITTEN to the newest transition instead of joined by a sibling - three
rapid changes leave one row per recipient saying where kitting ended up,
while the audit history keeps all three, uncoalesced. Found by the probe and
fixed: a row held while email was OFF stayed 'skipped' forever; the change
that finds email ON now promotes it to pending and schedules it - otherwise
turning the gate on silently orphaned everything coalesced before it.

The gate is T7.6's gate, reused - the probe greps that no second email flag
exists anywhere. Off by default; admin-only (403 for anyone else); every send
terminates at the in-process SMTP sink with count and recipients asserted; no
real mail leaves this branch. Send failures ride the shared notify.deliver
path whose failure handling qa_gate_check pins.

Verification (each probe run alone): NEW tests/kitting_notify_check.py 17/17
(the sink is imported from qa_gate_check - one sink implementation, not two).
Regression: qa_gate_check 40/40.

Items: CR-011, D10 (X1 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:05:24 -07:00