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>
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>
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
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)
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
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
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
A named person owns fulfillment of the kit - today that is Paul Coonrod,
informally, and everything bottlenecks through him. The package now records
it explicitly:
- The Warehouse owner control is a dropdown of project members (the same
roster the Owner picker reads). Picking someone stores BOTH the display
name (kitOwner - exports and old renderers keep working) and the account id
(kitOwnerId - the routing CR-011's notifications will read at T8.3).
- Confirmed Aug 18: the field lives ON the work package, not the project - a
package retargeted to a different warehouse notifies the right person
without touching the project. The wizard gets no field.
- A stored name with no matching account - typed before the field was
account-backed, or someone since removed from the project - is KEPT as a
selected "(no account)" option and round-trips unchanged. Removing someone
from the project breaks nothing.
- The dashboard filters by warehouse owner, options drawn from the owners
actually present in the data - a filter offering people with nothing to
fulfill is noise.
Verification (each probe run alone): kitting_check.py extended to 21/21
(T8.2 section: picker, id+name persistence, orphan survival, board filter).
Regression: generalinfo_check 49/49.
Items: CR-010
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>