60b5b0c1f23ea0224f4494040c6b849469725eb3
177 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 60b5b0c1f2 |
DEPLOYMENT: the Portainer variable list was missing two entries
Asked whether MICRON_DB_URL could live in Portainer rather than .env. It can -
for a Git-based stack it is the only route, since Portainer does not read a
local .env and every ${VAR} in the compose file resolves from the stack's
Environment variables.
But the note listing what to set there named POSTGRES_*, AUTH_SECRET_KEY,
BACKUP_ENC_PASSPHRASE and SMTP_PASSWORD only. MICRON_DB_URL was missing and has
been since it was added, and LDAP_REQUIRED_GROUP was missing because I added the
variable and never updated this list. That list is what someone follows when
standing the stack up.
Replaced with a table of every variable the compose file references, and what an
empty one actually costs. LDAP_REQUIRED_GROUP is the one worth reading twice:
unset means no group gate, so every account in the domain may sign in, and it is
SILENT - sign-in works, nothing looks wrong. That was observed first-hand today,
where a group had been configured, sign-in succeeded, and the group check had
never run.
Two encoding rules that pull in opposite directions, now stated together because
getting them the wrong way round is easy: MICRON_DB_URL is a connection URL and
must be percent-encoded; LDAP_REQUIRED_GROUP is a distinguished name and must NOT
be - its spaces and commas are legal as they stand. Neither takes quotes; a form
field is not a shell, and quotes become part of the value.
Also corrected the closing line, which claimed those were "the only credentials
in the system" while omitting the password embedded in MICRON_DB_URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| c6a100405d |
T10.8 - document that a pre-D13 local SQLite database rejects new sign-ins
Found while diagnosing a login failure that turned out to be an account
lockout. The local wpsuite.db has no alembic_version table - it was built by
create_all() before D13 - so users.password_hash is still NOT NULL with no
default while the current model has no such column. Verified on a COPY of the
database rather than reasoned about: provisioning a new account raises
IntegrityError: NOT NULL constraint failed: users.password_hash
The shape of it is what makes it worth documenting. Accounts already in the file
keep working, so the developer can sign in and nothing looks wrong; it breaks
only when a NEW person signs in, and it surfaces as HTTP 500, which reads as a
server fault rather than a schema one. Nothing anywhere told a developer their
existing database needed migrating.
The note gives the non-destructive fix - stamp a1b8c6d4e2f9 then upgrade head,
which runs only the drop and keeps the data - and says why a plain
`alembic upgrade head` would fail on such a file. Earlier in the session I
suggested deleting the database; stamping is strictly better, since deleting
discards test data for no benefit.
Production is unaffected: Postgres, migrations applied at container start.
Also updated the nested-group done-when to reflect what is now actually known.
The transitive matching rule WAS exercised against the live directory today for
a direct member of a 2,003-member group and returned a match, so the rule and
the filter are right on this estate. A genuinely nested case remains untested
for want of such an account, and the box is marked partial rather than done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 25bc5bcd3f |
member_of could not tell a failed search from a non-member
Reported by a user who is a direct member of the configured group and was being refused with "Invalid username or password". Get-ADUser confirmed the membership, so the fault was here. The connection is built with raise_exceptions=False. A search that FAILS therefore returns False and leaves conn.entries empty - which is indistinguishable from "no match" if you only inspect conn.entries, which is all member_of did. Every possible failure of the extensible-match filter presented to the user as "you are not in the group" while they plainly were, and produced no log line saying otherwise. Now: - conn.search()'s return value is checked. A failed search raises LookupError(GROUP_CHECK_FAILED) and logs conn.result together with the filter that produced it. GROUP_CHECK_FAILED counts as a config problem, so login() answers 503 rather than 401 - our fault, not the user's, and reported as such. - If the transitive query matches nothing, a plain memberOf equality check runs for DIRECT membership. If THAT matches, the person is a member and is let in: refusing a real member is the worse error. It logs a WARNING naming the matching rule, because that outcome means nested membership is silently not working on this connection and needs a human. The failure mode this replaces is the one that hurts most: correct configuration, correct credential, real membership, and a refusal that blames the password. Same shape as the two logging faults fixed just before it - the information existed and could not be read. ldap_auth_check still 32/32; the fake directory exercises both branches. A step-by-step diagnostic (bind, account lookup, memberOf dump, the nested query, the direct query, group resolution) is in the session scratchpad rather than the repo - it is a one-off aid, not a deliverable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| b97ccd7ad8 |
Fix test env isolation: a popped variable comes back from .env
Caught the moment a real LDAP_REQUIRED_GROUP landed in .env: ldap_auth_check's "a just-provisioned account appears in the Admin console list" started failing, consistently, with no code change that could explain it. start() removed LDAP_REQUIRED_GROUP from the subprocess environment so the test could run without a group gate. But the server imports server/db.py, which calls load_dotenv(), and python-dotenv skips only keys ALREADY PRESENT in os.environ - so a popped variable is helpfully restored from the developer's .env inside the child process. The test was quietly running against the real required group, the fake "outsider" account was refused by it, and the account under test was never provisioned. Setting the variable to an empty string fixes it: empty still counts as present, so load_dotenv leaves it alone. Fixed in both harnesses - ldap_auth_check and browser_check, the latter shared by 39 files - and corrected the fix suggested in BL-028, which said to pop MICRON_DB_URL and would therefore not have worked either. Worth stating as a rule: in this repo a test cannot assume an environment variable is ABSENT. .env re-supplies it in any subprocess. Force the value you want; never remove it. ldap_auth_check 32/32, browser_check re-run green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 3f4cd7ac92 |
Make a refused sign-in visible in the log
The reason a sign-in was refused was logged at INFO, in app.py and in ldap_auth. Nothing in this app configures the root logger, and uvicorn configures only its own - so an INFO record from wpsuite.* reaches no handler and is discarded. The message existed and could not be read, in exactly the situation it was written for: someone cannot sign in and the operator needs to know whether the credential was wrong, the account is outside the required group, or the group does not resolve. Raised to WARNING on the three refusal paths: app.py "sign-in refused for 'x' (not_in_group: not in CN=...)" ldap_auth "bind refused for 'x': 52e (bad password)" ldap_auth "bind succeeded for 'x' but the account is NOT in 'CN=...'" Left at INFO: provisioning an account, and normalising an address to a sAMAccountName. Those are narrative, not diagnostic. Config faults were already ERROR and were always visible, which is why the 503 path could be diagnosed and the 401 path could not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 332b74e5de |
T10.2 - actually implement the startup line the docs send people to
A gap, not a refinement. T10.2's task text said the login change "must add
visibility: a startup log line stating whether LDAP is configured", and three
documents tell an operator to run
docker compose logs api | grep -i "LDAP auth"
as the FIRST diagnostic when nobody can sign in. Nothing ever logged it. The
grep would have returned silence, during exactly the outage it was written for,
and silence reads as "the API never started" rather than "the API is fine and
the group is misconfigured".
Found while answering a question that the line exists to answer: a required
group had been added to .env, sign-in still worked, and there was no way to
tell whether the group had been checked or the value had simply not been picked
up. (It had not - the file was unsaved. Both readings were correct at the time
they were taken.)
Implemented as a FastAPI lifespan handler. It reports CONFIGURATION only and
opens no connection: startup must not be able to hang on an unreachable domain
controller, and a bind at boot would count against the AD lockout policy for
whatever account it used. ldap_auth.selftest() remains the reachability check -
it validates the certificate without binding, so it cannot contribute to a
lockout either.
Verified by booting the real app under uvicorn and reading the log:
wpsuite.api: LDAP auth enabled - ldaps://prime.local:636, domain prime.local,
CA .../prime-ca-chain.pem, required group: CN=Prime Employees,OU=Prime
Distribution and Security Groups,DC=prime,DC=local
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| eecd724e02 |
T10.7 - close four more done-when boxes offline; token_version was mis-specified
I had said all six remaining boxes needed the live environment. Four did not,
and saying so was lazy scoping. ldap_auth_check now covers them (32/32):
a just-provisioned account appears in GET /api/auth/users, the request the
Admin console makes - without which nobody could grant a JIT account access
granting admin through POST /api/auth/users/{id}/role, the request users.js
sends, with the role verified to have actually changed. D13 criterion 4 was
until now only asserted for role PRESERVATION, never for role GRANTING.
no AD error-49 sub-code appears in any response body, while _err49 does parse
one out of a real AD message - the log gets the detail, the caller does not
token_version invalidates a cookie already issued, and leaves other sessions
alone
That last box was wrong as written. It asked to exercise token_version "by a
role change", and nothing in app.py bumps it on a role change - or on a
deactivation. Neither needs to: get_current_user re-reads the account from the
database every request, so both take effect on the next request regardless.
T10.3's note that "role changes and deactivation should bump it" described an
intention rather than the code, and I repeated it without checking.
What token_version actually is now: a mechanism whose only trigger is the bump
manage_users makes on disable, which is belt-and-braces since is_active already
refuses the request. It works, and it is tested - but nothing much triggers it.
Logged as BL-030 rather than resolved here, because whether to wire it to
something (a "sign out everywhere" control is the usual shape) or remove it is
a session-handling design question, not an auth-wave bug.
Two boxes remain open, and both genuinely need your environment: member_of
against a real NESTED group, and the in-container openssl certificate check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 24fff382c8 |
T10.7 - cover the throttle and local is_active; tick what was verified
Two things, both found by checking the wave file before pushing rather than assuming it was current. First: most done-when boxes were still open even where the work had been verified, which would have told a reviewer that almost nothing was checked. Ticked the ones genuinely verified, each with what verified it, and left eight open that are not. One box was not merely unticked but WRONG - it asked that `create-admin` create an admin with no password prompt, and T10.9 removed that command outright; restated as what now has to be true. Second: two of the open boxes were safety-relevant and cheap to close, so ldap_auth_check now covers them (24/24): a disabled local account is refused 403 even though its bind succeeds - local is_active overrides the directory, which is how access to THIS app is revoked without touching the domain account the throttle stops CALLING the directory, not merely refusing. Proved by spending the attempt budget on wrong passwords and then presenting the CORRECT one: a 429 for a credential that would otherwise succeed is only possible if the check runs before the directory is consulted. Also asserts the budget is per-username, so throttling one account does not throttle everyone. That was the last untested safety-critical behaviour on the branch. It is the thing standing between an unauthenticated caller and locking colleagues out of Windows, and until now nothing exercised it. Also recorded a deployment fact at the top of wave-10 where it cannot be missed: LDAP_REQUIRED_GROUP is empty, and empty means no group gate - every account in prime.local may sign in. The live sign-in that confirmed this branch works was made without it, so it proved the bind, the certificate chain and provisioning, but not the group check. That path has still never run against the real directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| d261024139 |
T10.6 - before/after login screenshots at 390px and 1440px
Closes the last CLAUDE.md verification box on T10.6, which requires any task
touching the frontend to be exercised at both widths with screenshots in the PR.
Captured with the repo's own tests/baseline_shots.py rather than by hand; it
already treats login as the one page whose real state is signed out. "Before"
was shot from a detached git worktree at main (
|
|||
| 79787b3e9f |
T10.7 - full suite result recorded: 1284/1288 across 40 files
Ticks the last open box on T10.7. Two files fail and both were already logged
as pre-existing, with git diff main...HEAD showing this branch touches neither:
BL-028 (assets_check, whose "no MICRON_DB_URL" premise is violated by any
machine that has the var set) and BL-029 (generalinfo_check, flagging an rgba
literal last touched by
|
|||
| e0008670fc |
T10.5 closed, not built - the required group stays an env var
Proposed, examined, rejected. The wave file now records why at the task, so
nobody reads the original and assumes it was merely skipped.
Three honest points, in order of weight:
The console requirement was invented in the wave file, not asked for. D13
criterion 3 says "An AD group is configured" - not "configurable from the
console". LDAP_REQUIRED_GROUP satisfies the criterion as written.
The validate-on-save guard existed only to defend against a risk the console
itself introduced. A feature whose complexity exists to defend against itself
is usually the wrong feature.
And the lockout it defended against is already handled. Verified rather than
asserted: a group that does not resolve raises LookupError in member_of, which
verify() maps to GROUP_NOT_FOUND, which is_config_problem classifies as ours -
so login() answers 503 and the log reads "required group 'X' does not resolve
in DC=prime,DC=local - refusing the sign-in. This is a configuration fault, not
a bad password." A genuine non-member still gets 401. Already distinguishable.
Accepted losses, both recorded: an app admin cannot see the required group
without Portainer or shell access (a read-only diagnostic line was offered and
declined as unnecessary), and nothing validates the group until the first
sign-in attempt - which is unfixable, since resolving a group needs an
authenticated search, anonymous bind is disabled here, and there is no service
account by design.
Also logged two full-suite failures as backlog rather than letting them read as
D13 fallout. Both proven pre-existing: git diff main...HEAD shows neither file
touched by this branch.
BL-028 assets_check asserts "no MICRON_DB_URL" but start_server passes the
ambient environment through, so it fails on any machine whose .env
sets it. Same class of bug T10.7 fixed for LDAP_REQUIRED_GROUP.
BL-029 generalinfo_check flags rgba(20,30,50,.18) in the creator stylesheet,
last touched by
|
|||
| c47b2ae210 |
T10.7 D13 - the suite runs without a domain controller
Far smaller than estimated, because the premise was wrong. I had said four checks sign in and would each need a fake directory. They do not: seed() mints a session token with auth.create_token() and sets the cookie directly - browser_check's own docstring says so - and the only breakage was a leftover password_hash= kwarg on a model that no longer has the column. Deleting that one line in browser_check.seed() unblocked 39 files that import seed/start_server from it. launcher_check needed the same. console_dialogs_check's password-reset half is deleted rather than ported. Its docstring now records what went and where the prompt kit is still covered (wpPromptDialog has five callers left in wp-creation-app.js; creator_dialogs_check exercises them, validation included - verified, 20/20). Nothing was left skipped in place of the removed section. Exactly one check genuinely needed a seam: url_state_check drives the real login form to prove a deep link's ?next= survives authentication. That cannot be faked by minting a cookie, because the login round trip is the thing under test. The seam is env-driven because it has to be: start_server launches the app as a SUBPROCESS, so a monkeypatch in the test process would never reach the code doing the authenticating. server/ldap_fake.py reads WP_LDAP_FAKE_DIRECTORY and ldap_auth dispatches to it AFTER the empty-input guard, so the anonymous-bind guard covers the fake path too - a fake that reimplemented it would let the real one rot unnoticed. The production guard is the point of that module. An env var that makes any password work is exactly the kind of thing that escapes into production, and D13 left no other way in. is_active() refuses whenever a non-SQLite DATABASE_URL is configured - the same test auth._load_secret uses - and describe() shouts in capitals so a fake run can never be mistaken for a real one in the startup log. Two things found on the way, neither of them the app's fault: - url_state_check's "signing in continues to the requested page" asserted `"wp-creation-index.html" in location.href`. That string is in the ?next= parameter too, so it passed while sitting on login.html with the sign-in rejected. It would have passed with login entirely broken. Tightened to assert we actually left the login page. - Two assertions in my own new ldap_auth_check read the WRONG database: server/db.py binds its engine from DATABASE_URL at import, so setting the env var afterwards keeps reading whichever file was configured first. users_in() now opens the file it is asked about with sqlite3. The CERT_NONE check also had to become an AST walk - the module docstring names validate=ssl.CERT_NONE in order to explain why it is banned, and a text search cannot tell that apart from a real call. tests/ldap_auth_check.py is new coverage rather than repair: the anonymous-bind guard, CERT_REQUIRED by AST, the nested matching rule in the filter, the production refusal, a refused sign-in creating no account, and an existing admin still being an admin with their locally-set name intact. 20/20. Run so far, all green: browser_check 71/71, launcher_check 58/58, console_dialogs 12/12, url_state 23/23, qa_gate 41/41, critical_reopen 11/11, creator_dialogs 20/20, a11y 22/22, kitting_notify 17/17, ldap_auth 20/20. A full sweep of the remaining ~30 is running; its box stays unticked until it reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| dc0cee240e |
T10.8 D13/D14 - documentation matches what the code now does
DEPLOY-login-portal.md was the most wrong and is rewritten. It described taking a username/password portal live - bcrypt, and a first admin created with `create-admin --password`. Every command in it now fails. It keeps its filename and carries a note saying what it replaced, because an admin holding the old copy needs to know why the steps stopped working rather than concluding the deploy is broken. New content leads with the warning that there is no break-glass, and puts verification BEFORE announcing the deploy - the log line, the certificate check that binds nothing, then a real sign-in. DEPLOYMENT.md: AUTH_RESET_* replaced with the LDAP variables; the users table row no longer claims a password_hash column; "Self-service password reset" replaced by a section saying there isn't one and pointing at Okta. New "Domain authentication" section covering the three things that are not obvious - why prime.local and never a DC or an IP, why the CA bundle is not a certificate issued to this app (with the thumbprints and a Get-ChildItem line to rebuild it), and why the outbound network stopped being optional - plus the lockout arithmetic written out so the next person to raise AUTH_MAX_ATTEMPTS sees the constraint rather than a magic 2. server/README.md: endpoint table drops /api/auth/password and gains the role route; the login-portal section becomes domain authentication; create-admin becomes the two-step bootstrap (sign in, then promote). CLAUDE.md: a new "authentication rules" section beside the token rule, for the same reason that one exists - four things that look like tidying-up if you do not know why. The empty-password guard that must run before bind(), CERT_REQUIRED with an explicit CA file, AUTH_MAX_ATTEMPTS being arithmetic rather than taste, and connecting to the domain name rather than a DC. Plus: no break-glass, and roles are local - never read a role from AD. Closed three done-when boxes that were open rather than ticked: T10.8 all of them T10.9 promote/demote verified against a real bind (Aug 24), not a stub T10.3 the Postgres round trip, on postgres:16-alpine Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 8d49fb9248 |
T10.9 fix - the CLI's auth failure said nothing useful
"Authentication failed." and no more. I had carried the generic-message reasoning across from /api/auth/login, where one indistinguishable failure is correct because an unauthenticated caller must not be able to enumerate accounts. That reasoning does not transfer to a local CLI: the operator IS the account holder, there is nobody to leak to, and withholding AD's own sub-code just makes the failure undiagnosable. It cost a round trip to find that out. Now prints the reason, the AD error-49 sub-code, the exact bind string attempted, the server, and the password length - enough to tell apart a wrong password (52e), a locked account (775), an expired one (532), and the case that looks like a bad password but is not: 525, no such user, which means the BIND NAME is wrong rather than the credential. That last one matters here. The bind is <sAMAccountName>@LDAP_DOMAIN, and LDAP_DOMAIN defaults to the AD DNS name (prime.local). If an estate's UPN suffix is the mail domain instead, that string is not a valid bind name and AD answers 525 - which reads as "bad password" to anyone not looking at sub-codes. The hint says so, and says to set LDAP_DOMAIN to the UPN suffix. The password is never echoed or logged; only its length, which distinguishes "getpass read nothing" from "getpass read the wrong thing". /api/auth/login is untouched. Its generic 401 is still correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 495d87dd72 |
Fix T8.6 migration: boolean default that Postgres rejects outright
NOT part of D13. Found while testing T10.3's migration against a real Postgres,
which could not be reached because the chain dies two revisions earlier.
a1b8c6d4e2f9 (T8.6 / D6,
|
|||
| c5540ce6da |
T10.9 D14 - the CLI authenticates against the domain; create-admin/create removed
Accounts are not created here any more. D13 provisions them on first successful
sign-in, so create-admin and create were redundant - and worse than redundant,
because a hand-typed username can end up matching no directory identity at all.
Removing them means every row now originates from a bind, which closes that
class of problem for everything except the rows the old CLI already made.
promote and demote replace them. Bootstrapping the first admin is now two steps
in order: sign in once, which provisions the account at project_user, then
promote your own sAMAccountName.
Every state-changing command requires a prompted domain bind. No --password
flag on anything, deliberately: that would put a live domain password into shell
history and into ps output for every other user on the box. `list` needs no
credential so an outage stays diagnosable.
Two deliberate divergences from the API, both commented at the code:
- The bind does NOT apply the login group gate. If a mistyped required group
locks everyone out of the console, this tool must still work, or the only
route to fixing the lockout is the thing the lockout prevents.
- Changing your OWN role is permitted. set_user_role in app.py forbids it to
stop an admin locking themselves out of the console; here it is the entire
bootstrap path. Allowed, and recorded with {"self": true}.
Kept from set_user_role: the last-admin guard, and clearing auto_add_projects
on promotion to admin (an admin already reaches every project, so the flag
would sit there invisible and spring back on demotion).
What this is worth, said plainly in the module docstring rather than implied:
anyone with a shell here can still write to the users table with psql or
sqlite3, so the bind is defence in depth and mostly ACCOUNTABILITY. Before
this, every role change from a shell was invisible in AuditLog while the same
change through the console was recorded. Now both are recorded and both name a
person. Any-domain-user was accepted as sufficient knowing that.
Verified: the three removed commands are rejected as invalid choices; list runs
with no credential; a state-changing command with LDAP misconfigured refuses
rather than proceeding unauthenticated; promote, demote, self-promotion, the
last-admin guard, the unknown-account message, and one audit row per change all
behave, with the bind stubbed.
Left open rather than ticked: none of this has been run against a real bind.
authenticate_operator was stubbed for the logic tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 9f91e9e26b |
T10.3 fix - the drop migration was deleting every project membership
Found by actually seeding the pre-migration schema and rolling forward, rather than by checking that the column disappeared. The column disappeared correctly; project_members came back empty. batch_alter_table emulates ALTER on SQLite by rebuilding the table - create a new one, copy the rows, DROP the original, rename. server/alembic/env.py:22 imports the engine from server/db.py, which registers a connect listener setting PRAGMA foreign_keys=ON, so that DROP TABLE cascaded through project_members.user_id (ondelete="CASCADE") and took every membership row with it. No error, nothing in the log, and the users table looked perfect afterwards. Production would have escaped it - Postgres does a real ALTER TABLE DROP COLUMN and touches nothing else - so this was a local-dev and test-fixture data loss, which is worse in one specific way: the tests CLAUDE.md requires run against a throwaway SQLite database, so the suite would have been validating behaviour against silently emptied membership tables. Wrapping the batch in PRAGMA foreign_keys=OFF is not the fix: that pragma is a no-op inside a transaction and alembic runs migrations in one. The rebuild is simply unnecessary - SQLite has had native ALTER TABLE DROP COLUMN since 3.35 (2021), this runtime has 3.42, and Postgres has always had it. Plain op.drop_column touches one table and cascades nowhere. The reasoning is written into the migration's docstring as a DO NOT, because batch_alter_table is the reflexive thing to reach for when a migration has to work on SQLite and the failure is invisible. Re-verified with memberships in the fixture: 3/3 users survive, roles intact (admin still admin) 2/2 project_members survive downgrade -1 -> column back, nullable; upgrade -> gone again Also closed BL-026: notify.send_now removed. Nothing referenced it and its docstring described itself entirely in terms of password resets. send_email, which it wrapped, is untouched and still used by the outbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| ab7bce9f5b |
T10.6 D13 - strip the password UI; "Forgot password?" goes to Okta
login.html/login.js: the two reset views are gone along with the reset-token handling, and the sign-in form now says which password to type - "your Windows password, the same one you use to sign in to your computer" - using the .hint class the page already had, so no new CSS and no new literal. "Forgot password?" is KEPT and points at https://primecontrols.okta.com/. An earlier draft of this task deleted the link and I proposed a plain "contact IT" sentence instead; Okta is the better answer, and with no app password and no break-glass it is the only recovery path that exists. Three details that would each have broken it: - The old click handler on #forgot-link called preventDefault() to swap views. Left in place it would have silently swallowed the navigation, so the link would look right and do nothing. There is now deliberately no handler, and login.js says why so nobody adds one back. - target="_blank" without rel="noopener noreferrer" hands the opened page a window.opener handle back to the login page. - Worth recording since it was checked rather than assumed: the CSP allows this. form-action 'self' governs form submission, not link navigation, and no navigate-to directive is set - so a plain <a href> off-origin is fine and the nginx config needs no change. login.js also handles 503 distinctly now. T10.2 made that mean "the directory is unreachable or misconfigured", which is our fault - showing "invalid password" would send people hunting for a password they no longer have while a deploy is broken. Also removed, because T10.3 deleted the endpoints behind them and leaving them would have produced visible 404s rather than dead-but-harmless markup: auth-guard.js the whole change-password dialog (POST /api/auth/password) wp-sidenav.js the "Password / Change your password" menu entry that opened it users.js the per-row "Reset password" action users.js the password field in the create-account form - NewUserIn no users.html longer accepts one, so the form was posting a rejected field The self-row placeholder button pointed at a top-bar Password link that no longer exists; it is now a plain "you" marker. Verified: node --check passes on all four touched JS files; the only password references left in html/ are the sign-in form and the SMTP config in admin.js, which is unrelated and stays. Logged BL-027 rather than acted on: the Okta URL is the first sign of an Okta tenant on this estate, which means an OIDC flow is available in principle and would remove the domain-lockout hazard that forced AUTH_MAX_ATTEMPTS to 2. D13 was decided and reaffirmed and T10.1-T10.4 are built, so swapping the mechanism mid-wave is the reordering CLAUDE.md forbids. Recording is not reopening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 8cfb4c1008 |
T10.3 D13 - drop users.password_hash and every path that touched it
The irreversible one. The suite no longer stores a credential of any kind.
Removed from app.py: /api/auth/forgot-password, /api/auth/reset-password,
/api/auth/reset-available, /api/auth/password, /api/auth/users/{id}/password,
the four password-bearing input models, the reset throttle and mail body, and
the password arguments to create_user. Removed from auth.py: hash_password,
verify_password, password_problem, MIN_PASSWORD_LEN, _COMMON_PASSWORDS,
create_reset_token, decode_reset_token, RESET_MINUTES, and the bcrypt import.
Removed from notify.py: the password_reset_enabled feature flag. Removed from
manage_users.py: the password prompt and the reset-password command.
token_version STAYS. Password changes no longer exist, but a role change or a
deactivation still has to invalidate sessions that are already issued.
/api/auth/users/{id}/role stays, which is D13 criterion 4 - granting admin to
an existing account must keep working, and it does.
Migration b7e4f1a20c93 uses batch_alter_table because SQLite has no DROP COLUMN
before 3.35 and local dev runs on SQLite while production runs on Postgres.
downgrade() recreates the column NULLABLE rather than NOT NULL as the baseline
declared it: there are no hashes to put back, and a NOT NULL column with no
server default refuses to add itself to a table with rows. The docstring says
plainly that the downgrade does not restore the old login - it exists so the
revision is well-formed, not because stepping back is a recovery path.
Verified:
upgrade head from empty -> password_hash absent from users
downgrade -1 -> column back, nullable (notnull=0)
upgrade head again -> absent again
remaining /api/auth routes -> no password or reset route left
create-admin -> works with no password prompt
grep for the removed symbols -> nothing outside the migration and one
docstring that names the dropped column
NOT verified, and it is a done-when box left open rather than ticked: the
migration has only been round-tripped on SQLite. No Postgres is available here.
batch_alter_table takes the direct ALTER path on Postgres, which is the simpler
of the two, but "simpler" is not "tested".
notify.send_now is now orphaned - its only caller was forgot_password. Logged as
BL-026 rather than deleted in passing, because an immediate unqueued send is a
reasonable primitive to keep and that decision does not belong in an auth task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 0de746bc62 |
T10.4 D13 - provision on first sign-in without trampling existing accounts
Criteria 2 and 4 pull against each other: create accounts that do not exist, never touch the role of accounts that do. Two helpers in app.py keep the two cases apart so the role-preserving branch cannot be edited by accident. Matching uses sAMAccountName OR the directory's mail, per the Aug 21 decision. A bind can only carry one identifier, so the bind is sAMAccountName@prime.local, but matching an existing local row tries both - existing accounts were typed by hand with manage_users.py and some are short logon names while others are email addresses. auth.find_user already compares case-insensitively against username AND email, so two calls cover four columns. Verified against a throwaway SQLite database: existing admin -> role still 'admin' locally-set full_name -> preserved, not overwritten by the directory empty email -> filled from the directory local username is email -> matched by mail, project_admin kept no local row -> created at project_user, is_active, audit row ProjectMember rows -> 0 second sign-in -> same row, no duplicate, 3 users total A JIT account deliberately gets NO project access. The wave file said to honour the auto_add_projects machinery so a new account "lands in the right projects"; that was wrong about the flag, which is evaluated when a PROJECT is created to mark who joins every new job and cannot retroactively add an account to jobs that already exist. There is no correct default, so least privilege applies and the wave file's done-when has been corrected rather than quietly satisfied. The consequence is a UX cliff worth knowing about: a successful sign-in into an empty app until an admin grants access. That is why provisioning writes an AuditLog row and a log line instead of happening silently. is_active is checked after provisioning (a new account defaults active) and after the role branch (a disabled admin is still refused). Local is_active overrides the directory on purpose: disabling here revokes access to this app without touching the domain account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 2a3c4a83fa |
T10.2 D13 - login binds against the domain instead of comparing a hash
server/app.py: login() now calls ldap_auth.verify(). No password is read from the database on this path any more; the column is still there and unused until T10.3 drops it, which keeps this commit revertible on its own. THE THROTTLE IS THE POINT OF THIS COMMIT, not the bind. AUTH_MAX_ATTEMPTS defaulted to 5, which is exactly this estate's AD lockout threshold. Failures are now domain binds, and the API runs 2 gunicorn workers, so that default would have let up to 10 binds reach a DC - locking the account out of Windows twice over before the app's own lockout engaged. Anyone could have done it to anyone, unauthenticated, from the login form. Now 2, so the worst case is 4 against a threshold of 5, with the arithmetic written down at the constant so the next person to raise it sees the constraint. The persistent counter on `users` cannot cover this alone: under D13 accounts are created on first login, so a real domain account can be attacked before it has a local row. Added a username-keyed pre-account window for exactly that gap - keyed by username rather than client IP, because it is the domain account being protected, not this endpoint's capacity, and IP rotation would walk straight past an IP-keyed limit. Both throttles are checked BEFORE the directory is touched. 503 is now distinct from 401. LDAP unconfigured, unreachable, untrusted, or a required group that does not resolve are OUR faults and return 503, because D13 left no password fallback: reporting "invalid username or password" would send people hunting for a password they no longer have while the real problem is a broken deploy. A rejected credential and a missing group membership both stay a single generic 401 - the useful detail goes to the log, never the response, so account existence is not disclosed. Also added a logger to app.py, which had none. An earlier draft of this commit called log.warning() in a module where `log` was undefined; compileall does not catch a NameError, so it would have surfaced as a 500 on the first failed sign-in. Named wpsuite.api to match wpsuite.auth / .ldap / .notify. Verified: app imports, the route is registered, LOGIN_MAX_ATTEMPTS is 2. An authenticated user with no local row is refused with 403 until T10.4 adds provisioning - deliberately explicit rather than silently admitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 0577660c86 |
T10.1 D13 - the LDAPS client, verified against the live domain
server/ldap_auth.py: simple bind to ldaps://prime.local:636 as sAMAccountName@prime.local, nested-group membership via the LDAP_MATCHING_RULE_IN_CHAIN extensible match, and a selftest() that validates the DC certificate without binding so it can never contribute to a lockout. Verified against the live domain, not just reasoned about: selftest() to prime.local -> ok, "certificate validates" selftest() to 192.168.3.37 -> refused, untrusted (no IP SAN) empty / whitespace password -> empty_input, with Connection nulled out so any call to bind() would have raised missing CA file -> unconfigured, is_config_problem=True Tls.validate -> ssl.CERT_REQUIRED, explicit ca_certs_file Three things here are load-bearing and commented as such at the call site: - The empty-password guard runs BEFORE bind(). An LDAP simple bind with an empty password is an anonymous bind and it SUCCEEDS, so without the guard a blank password authenticates as whatever username was submitted. - No `version=` pin on Tls. An earlier draft of this file pinned PROTOCOL_TLSv1_2, which would have silently downgraded every connection from the TLS 1.3 these DCs actually negotiate. - Retries cover connect failures only. A rejected credential returns immediately, because every failed bind counts against the domain lockout policy and this endpoint must not become a way to lock people out of Windows. The trust anchor is server/certs/prime-ca-chain.pem - PRIME CONTROLS ROOT CA plus ISSUING CA 1, public certificates with no private key, checked in because they are public and long-lived (2051 / 2036). The system trust store is deliberately not used: it currently trusts five other self-signed CAs on this estate. LDAP_CA_FILE overrides the path for a mounted bundle. docker-compose.yml: the `outbound` network is no longer optional. Its comment said to detach it if you were not using the Micron asset picker; doing that now breaks every sign-in, since `internal` has no default gateway and therefore no route to prime.local:636. Not yet verified, and called out rather than assumed: the nested-group case needs a real group with a nested member, and the in-container `openssl s_client -CAfile` check needs the stack. Both are T10.1 done-when boxes still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 3c9343dfc8 |
D13 / wave 10 - the decision and the task file, before any code
Authentication moves to the domain over LDAPS. Nothing in the spec covered auth against a directory, so per CLAUDE.md this is new scope with a new `D` id rather than a widened old one. D1-D12 were taken. Records what was verified against the live environment on Aug 21 rather than assumed: LDAPS on 636 with TLS 1.3, the DC cert chain up through PRIME CONTROLS ISSUING CA 1 to a root valid until 2051, six DCs in the SRV record, and `prime.local` in every DC cert's SAN - which is why the client connects to the domain name and not to a DC or an IP. Also records why the certificate already serving the site is not usable for this: it is a Let's Encrypt DV cert held by an OpenResty host outside this repo, and it carries no relationship to prime.local. The question was asked directly and the answer is not obvious, so it belongs in the record. Eight tasks, T10.1 through T10.8. Two questions are left open in the decision doc to be answered rather than guessed - break-glass access when the DC is unreachable, and whether existing usernames match sAMAccountName. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 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> |
|||
| 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 (
|
|||
| 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>
|
|||
| 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> |
|||
| 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>
|
|||
| 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>
|
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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>
|
|||
| 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 |
|||
| 8cf8c0f882 |
D11 - merge origin/Micron-Assets: the Micron asset picker, adapted to R2
Integrates Cody Schaefer's
|
|||
| 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> |
|||
| 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> |
|||
| 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>
|
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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>
|
|||
| 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> |
|||
| 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> |
|||
| 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>
|