Commit Graph

39 Commits

Author SHA1 Message Date
222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
Nick's real location list hit the production import and got 'Internal Server
Error' with no line number - BL-027's class again, three days after the
migration outage: Postgres enforces VARCHAR lengths and refuses control
bytes, SQLite shrugs at both, and the importers were only ever rehearsed on
SQLite. Reproduced both hazards locally (an over-long value and a NUL byte
import cleanly on SQLite; either 500s Postgres wholesale).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Items: C4, D11.

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

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

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

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

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

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

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

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

Items: D7

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

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

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

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

Items: CR-013, D6

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

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

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

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

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

Items: D6

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

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

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

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

Items: CR-011, D10 (X1 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:05:24 -07:00
c084f730b3 T7.7 - CR-007/D8: the sheet travels with the package, and opens offline
The field wants the specific PDF attached, not a link to a Bluebeam session.

Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.

The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.

Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).

Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.

Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.

Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.

Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.

Items: CR-007, D8 (X8 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:09:35 -07:00
2486f87010 T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off
Marlena's ask: QA sees inbound work ahead of time, not after the fact.

The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders
(wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field
View's list - inside the T7.3 transition model, not beside it: entering it
from an unreleased state crosses the release gates, and 'Issue' (hold) stays
a branch. The dashboard filter and the navigator grouping learned the state
from the ladder without their own edits.

Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP
wizard's team step, stored as account ids at data.sop.project.qaGroupIds.
Entering Ready for QA emails that list and nobody else. A rejection emails
the owner AND the same list (amended answer), returns the package to In
Progress, and REQUIRES a fresh comment - server-enforced on both write paths
(the first version accepted any old comment already on the record, which made
every rejection after the first one free; the gate now demands a new entry).
Accept and reject are real buttons on the release banner; the comment modal
enforces its field; qa_ready / qa_rejected / status_changed all land in the
audit history.

The link (X1): wp_link() now opens THE package - wp-creation-index.html
?project&wp=<id>, which the creator boots directly and login.html?next=
round-trips for a signed-out recipient. It previously pointed at the suite
root, which is exactly the failure X1 names; assignment mail inherits the fix.

Email discipline (D10 + standing rules): ships OFF (the stored setting the
admin console already owns; PUT /api/settings is admin-only, 403 for anyone
else, and audited). With it off, transitions write outbox rows marked
'skipped' and the sink receives nothing. With it on, the probe runs a REAL
SMTP conversation against an in-process capture sink and asserts the count
and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row
with the error recorded. The SMTP password exists only in the environment.

DEVIATION, stated: the task's Do-paragraph asks the email to include location
and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in
a message body. The done-when wins: bodies carry the WP number, who moved it,
and the deep link. A location canary planted on the package is asserted absent
from every captured message. If the fuller body is wanted, that is a product
call - needs Nick.

Found while building, logged not fixed (BL-021): project_sop_team() reads
sop.data['project'], a path pushSOP never writes - the critical-reopen email
has never actually reached the PM/CM. One-line fix, owned by T9.9.

Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px.

Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40.
Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16,
frame_check 39/39, validation_check 83/83.

Items: CR-014, D2, D9, D10 (X1, X3 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:54:07 -07:00
2b597e68d8 T7.3 - CR-015/A1/D4: the hold clears when the constraints do
ROOT CAUSE, exactly (the done-when asks for it):
Hold state was stored, twice, and derived nowhere.
1) Client: submitHold() wrote prevStatus='Issue', destroying the status the
   hold interrupted at the moment it was placed - there was never anything to
   return to. Clearing the last constraint then fell into the "Mark it as
   Issued now?" confirm, because STATUS_ORDER.indexOf('Issue') is -1 and -1
   reads as "before Issued". Decline it and the package stayed on hold with
   zero open constraints, forever - the exact state reproduced live in front
   of the Micron team.
2) Server: server/app.py's STATUS_ORDER put "Issue" at index 4, so
   _released('Issue') was true and every transition OUT of hold skipped
   enforce_release_gates() as "already released". POST /api/wps/{id}/status
   could walk a held package to Issued past its open constraint. The comment
   claimed the ladder was "mirrored in the front end"; the front end's ladder
   has no 'Issue' in it at all.

What changed:
- setConstraint() recalculates hold state on EVERY constraint change: clearing
  the last open constraint on a held package releases it immediately - no
  refresh, no dialog - back to the status recorded on the hold entry (`from`),
  which now rides on data.holds and survives save/reload.
- Every hold and release is history: pkgHolds entries carry ts, by, from/to,
  reason; the exported Hold Log gained a By column; the server writes
  hold_logged / hold_released audit rows (with the reason from data.holds) on
  both the upsert and the /status endpoint.
- _released() no longer counts the hold: 'Issue' is a branch, not a rung.
  Leaving hold to a field state re-runs the gates; entering hold never did and
  still does not. The critical-reopen email keeps its old reach ("has been in
  the field" includes on-hold).
- A1 preserved by name and by test: confirmEarlyRelease() still the one place
  a gate override is written (comment-stripped grep asserts exactly one
  pkgGateOverride assignment), still reason-first, still logged server-side.

D4 - what Urgent does (amended Aug 18): surface the audited path, add no new
one. confirmEarlyRelease() now also covers open constraints, but only for an
Urgent package, and the override must NAME every constraint it crosses - the
server refuses coverage by an old reason. The release banner gives an Urgent
package the override as its primary action (a real <button>); Normal and High
see nothing new and keep the same hard refusal, asserted per priority.
Banner button styled from tokens only; the banner now wraps at narrow widths.

Product question raised, not decided (per CLAUDE.md "asking versus assuming"):
Issue (hold) remains selectable from Draft and Scheduled, as it was before.
The done-when names no state list, so nothing was restricted. If a pre-release
hold is meaningless, closing it off is a one-line follow-up - needs Nick.

Verification (each probe run alone): NEW tests/hold_check.py 50/50, including
the clear-last-constraint regression specifically, the D4 priority matrix
against the server (six 409/200 cases), hold_logged/hold_released audit rows,
and an AST sweep proving every wp.status assignment in server/app.py sits in
a function that runs enforce_release_gates. Regressions: frame_check 39/39,
aggregates_check 16/16.

Items: CR-015, A1, D4 (X2 correction already recorded Aug 18)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:05:57 -07:00
7ef1fcdd96 Add Micron asset picker to work package creator
Adds an optional read-only Micron asset catalog lookup for the WP creator, with searchable asset IDs, CSV import, and graceful fallback to manual asset entry when the catalog is absent or unreachable. This includes the backend /api/assets endpoint, SQL Server connector configuration, Docker network changes for outbound access, and UI updates/documentation to make the catalog read-only and clearly distinguish Micron-vetted assets from manual entries.
2026-08-18 14:56:55 -05:00
4d186ea6a0 T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up
CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.

CR-004 - three dependent dropdowns

  Building filters Floor filters Sector, off the project's own taxonomy from
  T5.4. Clearing a parent clears its children: not doing that is how a package
  ends up filed under a floor that is not in the building it claims.

  PATHS are stored, not names and not bare codes. A floor's own code is not
  unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
  by a building and match everything beneath it with a prefix test, and it is
  what CR-018 groups on.

  The list is fetched with include_inactive=true, which is not a contradiction of
  CR-005's "deactivating hides it from new work packages" — they are two
  questions. What may be CHOSEN is active only. What may be SHOWN is everything,
  because a package already referencing a deactivated value still has to render
  its label, and blanking it on open would write the blank back on the next save.
  A deactivated value that IS on the package is offered, labelled "(no longer
  offered)"; on a fresh package it is not offered at all. Both checked.

  X5, checked the way aggregates_check checks its own: localStorage is poisoned
  with a fake building and the dropdown is required to ignore it.

  wp_location survives as a hidden field. A package written before this keeps
  what it said, and the form says so rather than dropping it.

CR-018 - the rollup

  LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
  "only this tuple and the keys inside each group change - the response shape
  does not", and that held exactly.

  Rolled up at EVERY level, server-side, not just at the leaf. "How many on
  floor 2" is the question CR-018 asks and it is a level above the leaf groups;
  summing them in the browser would be the same per-browser arithmetic B4
  removed. Actual Hours rolls up along the same dimensions - that is the field
  CR-017 retained, and this is why that decision mattered.

  Packages with no location are an explicit "(unassigned)" row, not a gap. The
  reason is arithmetic: a group set that silently omits them does not add up to
  the project total, and a rollup that does not reconcile is decoration. The
  probe checks every level sums to the project total, and to the estimated and
  actual hour totals, using distinct primes so a mis-sum cannot land on the
  right number by luck.

  A package with a building but no floor lands in the floor-level unassigned row
  alongside the one with no location at all - which is the honest answer, and is
  asserted by its hours rather than by its count.

  Free text captured before CR-004 groups under itself as a building rather than
  collapsing into unassigned, one level deep. Pretending free text is a
  hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".

  server/app.py                 dimensions, _location_levels, hours per group
  html/wp-creation-index.html   three selects where the text box was
  html/wp-creation-app.js       the pickers, the filters, the rollup panel
  html/wp-creation-styles.css   .loc-picker, .loc-rollup
  tests/rollup_check.py         new - 63 checks

Done when — CR-004
  [x] all three render as dropdowns populated from project configuration
  [x] dependent filtering works, and clearing a parent clears its children
  [x] values persist as codes; confirmed by reading what collectPackage stored
  [x] the dashboard filters by each of the three
  [x] a work package referencing a deactivated value still renders correctly
  [x] all option data comes from the server - proved by poisoning the cache

Done when — CR-018
  [x] the dashboard groups and totals by Building, Floor and Sector
  [x] totals reconcile against an unfiltered count, at every level
  [x] Actual Hours rolls up along the same dimensions
  [x] grouping is computed server-side - proved by putting nine fake packages in
      localStorage and requiring the panel to show none of them
  [x] work packages with no location appear in an explicit unassigned group

No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.

Verified one at a time
  rollup_check      63/63  new
  generalinfo_check 49/49
  browser_check     71/71
  pipeline          43/43
  a11y              22/22
  aggregates        16/16
  f_items           F1-F5 FIXED, F6 REPRODUCES (T7.2)

Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 00:24:00 -05:00
2081c1ad3c T5.4 - CR-005: a per-project location taxonomy, stored as codes
CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.

CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.

DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.

Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.

One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.

No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.

  server/models.py                    LocationNode
  server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
  server/app.py                       GET/POST/PATCH + import, parser, slug
  html/work-package-suite.html        step 11, an 11th rail button
  html/work-package-suite-app.js      the step's logic; LAST_STEP replaces 10
  html/work-package-suite-styles.css  the list, the report
  html/theme-light.css                .field-error, now declared once
  tests/locations_check.py            new - 58 checks
  tests/stepper_check.py              STEP_COUNT 10 -> 11

Done when
  [x] CSV upload and paste both work and report rejected rows with reasons
  [x] duplicates are detected and reported rather than silently merged
  [x] values are editable after import - rename, add, deactivate
  [x] deactivating hides it from new work packages; an existing package
      referencing it still resolves, because the row is retained
  [x] values are stored as codes suitable for grouping
  [x] no guessed real-world floor names exist anywhere in the code

Two decisions worth disagreeing with

  Step 11, appended, not step 2, inserted. Locations belong beside Project by
  subject. Renumbering 2-10 would touch every sop-step-N id, every
  collectStepData case, every gate key and the analytics history - a large
  silent-mismatch surface for an ordering change. The count now lives in one
  place (LAST_STEP), so reordering later is cheap.

  Any project member may edit the list, not only a Project Admin. It matches how
  the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
  completed SOP, not on writing one. If the location list should be tighter than
  the SOP it belongs to, that is a product call.

Verified one at a time
  locations_check  58/58  new
  stepper_check    70/70  (11 steps)
  browser_check    71/71
  a11y             22/22  sop now rings 38 focusable elements
  url_state        23/23
  autosave         34/34
  aggregates       16/16
  pipeline         43/43
  launcher         58/58
  f_items          F1-F5 FIXED, F6 REPRODUCES (T7.2)
  alembic          upgrade / downgrade / upgrade all clean on a throwaway SQLite
                   file, and the migrated schema matches Base.metadata.create_all
                   column for column - dev auto-creates and production migrates,
                   so a divergence between the two is invisible until it ships

.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.

Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:06:27 -05:00
b670ae719d T4.1 - B4: the counts come from the server, and disagreement is said out loud
The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.

SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.

Two things moved to the server rather than being duplicated there:

  - PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
    wp-creation-app.js; the JS copy is deleted rather than left in place, because
    two copies of a weighting table is how the two drift apart.
  - "release-ready" now counts `waitingOn` predecessors as blocking, which the
    browser did and the old endpoint did not. Without that the phrase would have
    changed meaning the moment the dashboard stopped computing it locally.

New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.

by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:

  {"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
    "release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}

Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.

CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.

Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.

THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.

VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:

  - root and pat get byte-identical aggregates for the same project
  - the dashboard shows the server's total after localStorage is POISONED with a
    different package list; it cannot do that if it is summing the cache
  - a simulated outage renders "Counts unavailable", names the failure, offers a
    retry, renders no tiles beside it, and announces via role=alert
  - the launcher reports the SOP complete against a cache that says otherwise,
    and says "Could not check" when the request fails
  - by_location's groups are keyed by its declared dimensions, carry their own
    rollups, and sum to the project total

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:24:12 -05:00
357712e93e T1.6 - S13: seed_demo.py can seed a running instance again
Every /api/ route but /api/health requires a session and the script sent none,
so it could not seed anything. It predates the commit that taught the smoke test
to sign in.

It now signs in the same way, reusing smoketest.py's build_opener rather than
growing a second cookie-jar implementation - one login flow, one place to fix.
Credentials come from WP_SEED_USER / WP_SEED_PASSWORD, falling back to
WP_SMOKE_USER / WP_SMOKE_PASSWORD so one set serves both scripts, and it signs
out in a finally.

No bypass, no debug flag, no unauthenticated seeding route: the diff touches
server/seed_demo.py and nothing else, adds no route decorator anywhere, and the
33 get_current_user dependencies in app.py are untouched. The script
authenticates like a client; the server is not weaker than it was.

Two things found while fixing it:

The failure mode was worse than a refusal. call() swallowed the HTTPError and
returned the error body, so a 401 surfaced as a KeyError on proj["id"] three
lines later - which reads like a broken stack rather than a missing session.
Writes now go through expect(), which stops on the first refusal and prints the
status and detail.

Running it twice used to print a note that scrolled past and then create a
second identical DEMO project, leaving two of everything with no way to tell
them apart. It now refuses, names what exists, and prints the --clean command.

Also corrected the header's own instructions, which said the seeded SOP and Work
Packages would NOT render in the UI because the front end still read them from
localStorage "pending Phase 2 wiring". That stopped being true when the sync
layer landed. Selecting the seeded project now shows 7 Work Package cards in the
Field View, so anyone using the UI to check whether seeding worked is no longer
told to expect nothing.

Verified against a freshly started instance: no credentials aborts cleanly with
exit 2 and no traceback; a first run exits 0 and seeds a project, a complete SOP
and 9 packages; a second run exits 1 without duplicating; the data is visible in
the picker, the hero, the app bar and the Field View; --clean removes it and
exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:52:31 -05:00
64a5fd5612 Make the smoke test sign in; enforce SQLite foreign keys
Closes known issue 3. server/smoketest.py predated the login portal and had no
login step at all, so auth_gate refused every route after /api/health and the
documented way to verify a deploy reported a wall of failures against a healthy
stack.

  - Signs in first, holding the session in an http.cookiejar on a shared opener.
    urlopen() has no cookie support, which is why the session was dropped.
  - Credentials from WP_SMOKE_USER / WP_SMOKE_PASSWORD, or --user/--password, so
    a password need not land in shell history. Refuses to start without them
    rather than running headlong into 401s.
  - Checks the signed-in role up front and warns when it cannot archive or delete
    a project, instead of failing six checks later for an unexplained reason.
  - New exit code 2 for "could not run" (unreachable, or credentials missing or
    rejected), kept distinct from 1 "ran and found problems".
  - Also asserts the session is accepted on an authenticated route and refused
    after sign-out; signs out at the end so a run on a shared host leaves none.

The working smoke test immediately caught a real bug: SQLite ships with foreign
keys disabled and the pragma is per-connection, so every ondelete="CASCADE" was
silently a no-op on dev while working on Postgres. Deleting a project orphaned its
SOPs, work packages and membership rows; deleting a user orphaned theirs. db.py
now sets PRAGMA foreign_keys=ON for SQLite, so dev matches production.

Enforcing them exposed two things that had been getting away with it:

  - create_user adds an account and its ProjectMember rows in one flush, and the
    ORM takes flush order from relationship() declarations. models.py has none by
    design, so it emitted the child INSERT first and the database rejected it.
    Fixed with a db.flush() after the account, and documented at the top of
    models.py so the next same-flush pair does not rediscover it. The other three
    call sites already commit the parent first.
  - A write aimed at a since-deleted project used to leave an orphan row; with FKs
    enforced it would have been an IntegrityError surfacing as a 500, which the
    browser outbox retries forever (it only retires 4xx). require_project_writable
    now refuses a vanished project with 409, like the archived case beside it.

Verified: smoke test 27/27 exit 0 against a live server (the cascade assertion now
passes on SQLite, which is what used to fail); credentials missing and credentials
rejected both abort cleanly with exit 2 and no stray PASS lines; a project_user run
warns up front and fails as described. Scope tests 93/93, live HTTP checks 29/29,
static JS checks 33/33. No orphan rows left in the database afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:19:36 -05:00
4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00
928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00
e3527a6e1d Act on the fragility audit: boot-order crash, real cache correctness, deep links
A 55-agent audit of the last few commits confirmed 32 findings. The high and medium
ones are fixed here; the ranked leftovers are listed at the end.

Boot-order crash (my regression, wave 2)
- wp-format.js loaded AFTER wp-creation-app.js on every page, but the creator boots
  synchronously at parse time and its comment renderer calls wpFormatDateTime(). With
  any review comment present that threw a ReferenceError and aborted the rest of boot.
  The formatter now parses before the app scripts on all five pages. Verified with a
  comment seeded: the date renders and boot completes.

The network-first fix didn't actually work
- `fetch(req)` inherits the request's default cache mode, so it consults the browser
  HTTP cache — the previous commit's "network-first" still allowed a page to run
  against a stale sibling. Code is now fetched with cache:'no-cache' and precached
  with cache:'reload'.
- Nothing pinned freshness on the wire either: no Cache-Control anywhere, so browsers
  applied heuristic caching (~10% of a file's age) and each file expired at a
  different moment. NGINX and the dev server now send no-cache for html/css/js/
  webmanifest; images stay cacheable. Verified on the wire.
- Non-ok responses were returned verbatim, so a 502 broke pages the cache could have
  served; they now fall back to the cache. Cache keys drop the query string, which
  fixes both the offline miss on every in-app link (?project=…&tab=…) and unbounded
  cache growth. respondWith can no longer resolve to undefined. Cache bumped to v5.

Embedded creator
- Dropped the &t=Date.now() cache-buster and made the frame's identity the PROJECT.
  The view and which package to open are now applied by calling into the loaded
  document, so switching tabs no longer reloads it — that reload discarded unsaved form
  edits, made the creator unreachable offline, and stored a fresh copy per click.
- ?view=dashboard was re-read on every tab switch, so after one deep link the
  "Work Package Creation" tab kept opening the Dashboard for the rest of the session.
  Deep-link params are consumed once now.
- ?wp=<id> — which the global search has been emitting since wave 2 — was read by
  nothing, so picking a work package in search opened a blank one. The creator now
  exposes openWpById() and the shell applies it after a new 'wp-creator-ready' event,
  because the frame's load fires before pullProject() resolves.
- Math.max(320,…) could make the frame taller than the space available while page
  scrolling was disabled, pushing content off a window that couldn't scroll. Full-bleed
  is now only used when at least 460px remains, and the SOP-incomplete gate never runs
  inside it. A ResizeObserver re-measures when wp-chrome.js grows the app bar.

Contract drift
- .field-hint and .user-pick are used on the SOP suite page but their only rules lived
  in wp-creation-styles.css, which that page doesn't link — the CM hint and the
  sign-off pickers had no styling at all. Rules added to the suite's stylesheet.
- The creator's critical floor now also hides modal overlays (a stale stylesheet
  rendered their contents inline in the form) and gives the jump bar a sane sticky top.
- login.js dereferenced ids unguarded where the old version guarded, so a cached older
  login.html would break sign-in itself. Guarded.
- The "Language & time" menu item was added only if wp-format.js had already parsed;
  the check now happens at click time.

Verified: 157 API checks across five suites on a clean database, plus 22 driven UI
checks — boot-with-comment, tab switching with a no-reload probe, short-viewport
fallback, and the search deep link landing on the right package.

Not done, ranked: ~50 dead CSS rules across three stylesheets; dead .team-pick and
.constraint-option contracts; wp-chrome.js's documented '.header' mount branch is
unreachable because the creator loads neither wp-chrome.js nor its CSS; the squeeze
half of the embed layout (.content-area.embed-full) is still CSS-only, which degrades
to the old narrow column rather than breaking; fingerprinted asset URLs would make a
mismatched pair unrepresentable rather than merely unlikely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:07:20 -07:00
fcba74b584 Fix the WP navigator and the squeezed embedded layout; add per-project permissions
Layout — the reported "skinny scrolling windows"
- .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded
  Work Package Creator ran in a ~930px column with its own scrollbar inside the
  page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes
  full-bleed: the iframe fills the window below the app chrome and owns the only
  scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its
  flex-basis overrode `height`, leaving the used height indefinite so the child's
  `height: 100%` collapsed the iframe to its 150px default.
- The SOP wizard's fields were one per row; they now flow into ~340px columns.

Navigator — now an auto-hiding drawer
- It was a fixed 262px column that stole width from the form AND was hidden below
  1100px, so embedded (the normal path) it never appeared at all — that's the
  "broken side menu". It's now an overlay drawer behind a slim always-visible edge
  handle: hover or tap to open, move away / Escape / pick a package to close, or pin
  it to keep it open (pinned shifts the form and the page chrome across, and is
  remembered). A gutter keeps the handle off the section-nav chips.

Bugs found while checking the site over
- collectStepData() still read the SOP team fields as text inputs, but wave 1 made
  them account pickers — so it wrote a user ID into state.team.pm where the display
  NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly
  from the pickers.
- loadSampleData() set .value on those selects with fictional names; setting an
  unmatched value on a <select> silently does nothing, so the sample lost its team.
  It now stores them as names without an account, which the picker shows as
  "(no account)".
- My earlier CSS block replacement had deleted the SOP-chip, people-picker and
  critical-tag styles. Restored.

Same picker everywhere the SOP names someone
- Sign-off roles (step 3, required and optional) are account pickers now, storing
  userId alongside the name, so a signature belongs to an account that can be
  notified. Titles stay free text.

Per-project permissions (asked for: "change project permissions for individual users")
- project_members.role overrides the account's role on that project, so a PM on one
  job can be a Project User on another. Empty = inherit; app admin is admin
  everywhere. effective_role() feeds require_project_admin, so WP delete, completed-
  SOP edits and project delete are all judged per project.
- Project access is now its own column in the admin console (it was buried among the
  action buttons, which is why it couldn't be found), showing the project count per
  account; the dialog sets access plus the role on each project.
- The members endpoint reports each person's effective role on that project.

Verified: 157 API checks across five suites on clean databases (44 permissions +
22 password reset + 34 search/localization + 39 gates/notifications + 18 new
per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven
in headless Chrome, and probes confirming the team/sign-off pickers populate and no
longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080.

Service-worker cache bumped to v3 so browsers pick up the new shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:22:19 -07:00
b38348e6ae Wave 3: predecessor references with a release gate, and critical-constraint reopen alerts
Predecessors are real references now
- data.predecessors holds work-package ids, replacing a free-text SOP phase label
  that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase
  survives beside it as the descriptive "Sequence phase" field.
- readiness() has two gates: constraints clear AND every predecessor Closed. The
  banner, sticky bar, left rail, dashboard Gates column and the ready counters all
  reflect the second one.
- Enforced server-side by enforce_release_gates() on every path that sets a status
  — the plain upsert included, since that's how the browser and the offline outbox
  save. /issue and /status would otherwise have been ways around it.
- Cycles are refused directly and through a chain, with a message naming the
  package that already waits on this one. The Creator's picker also hides itself
  and its own descendants, so a cycle is hard to build in the first place.
- A deleted predecessor does not block: it would freeze everything downstream of a
  package someone removed.
- The gate is refusable, on purpose. Planners release ahead of upstream close-out,
  so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit
  event naming what was skipped, and prints on the package. A blank reason is not
  an override, and changing the predecessor set clears it. The dashboard won't
  release a blocked package at all — it points at the form where the reason is
  captured.

Critical constraints reopened after release
- Reopening a SOP-critical constraint on a released package emails the owner, PM,
  CM and the package's distribution list (minus whoever did it) and writes a
  constraint_reopened audit event.
- Detected by diffing the incoming constraints against the stored ones inside the
  normal upsert rather than via a new endpoint: the sync outbox only replays
  POST /api/wps, so a dedicated route would be lost offline. It fires only on a
  real cleared→open transition, so re-saving an already-open constraint doesn't
  re-announce, and never before release or for a non-critical constraint.
- Bodies carry the constraint name, WP number and a link — never package contents.

Verified: 139 API checks on one fresh database (44 permissions + 22 password reset
+ 34 search/localization + 39 gates/notifications), including every bypass path,
cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four
recipients confirmed both in the outbox and on the wire against a local SMTP sink.
27 driven UI checks against the real Creator page in headless Chrome covering the
picker, the override prompt (accept and cancel), override invalidation, the cycle
exclusions and the dashboard refusal. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:15:36 -07:00
61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3)
- BIM card: LOD removed, IFF # added next to the coordination status, and required
  once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable.
  A LOD already stored on a package is preserved and shown as legacy, not blanked.
- The blue "from SOP types" subtext under a field is now a SOP chip on the label
  with the detail in a tooltip. The chip stays visible rather than hover-only:
  field tablets have no hover, and "this came from the SOP" is the part that
  matters. The hint elements stay in the DOM (hidden) so the code writing to them
  keeps working; an observer mirrors their text into the tooltip.
- Specification Section is no longer typed per package. Each WP type carries a
  spec section on the SOP; the field is read-only in the Creator and follows the
  type, with the SOP's spec folder linked underneath. This reads both spec
  comments as one intent — stop typing it, derive it.
- Assignees and Distribution are multi-selects over the SOP project team, showing
  each person's job function, with the CM pre-added to Distribution (removable per
  package) and a free-text option for people with no account. The stored display
  strings are unchanged so print/export/dashboard keep working; account ids ride
  alongside for the notification work in wave 3.

Localization + time
- Per-user locale/timezone (Language & time in the user menu), an app-wide default
  in the admin console, then the browser. Timezones are validated against the
  server's zoneinfo and the picker is fed from it. Calendar dates are formatted
  from their parts so a due date never reads a day early in another zone.
- Every displayed timestamp now goes through the shared helpers.

Top-bar chrome
- Project switcher beside the logo and a centered global search, injected into
  either generation of top bar; skipped in an iframe so the embedded Creator
  doesn't get a second one. Ctrl/Cmd-K focuses search.
- GET /api/search covers work packages, projects and SOPs, scoped to the caller's
  projects, hiding archived packages, with LIKE wildcards escaped.

Fixed along the way: showForm() cleared every card's inline display, which undid
applyKind() — so the Package Type and BIM cards reappeared on an install-only
project. Split out applyKindVisibility() and re-apply it there.

Verified: 100 API checks on a fresh database (44 permissions + 22 password reset +
34 search/localization), 24 driven UI checks against the real Creator page in
headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags,
BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:51 -07:00
79b0e955b4 Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag
Acts on the site comments from 8/3 plus the follow-ups. Foundation work first —
four of the comments all needed the project team to resolve to real user accounts.

Permissions vs project role (new)
- User.role is now the PERMISSIONS role: admin | project_admin | project_user.
  project_admin may delete work packages, change a SOP after it is complete, and
  delete a project; project_user may not (archiving a WP is still open to them).
  Enforced by require_project_admin() server-side; the UI only hides dead ends.
- New User.project_role holds the person's JOB FUNCTION on the project. It grants
  nothing — it feeds the SOP team pickers and notification routing.
- Admin console shows both columns and explains the difference. Migration rewrites
  the legacy role 'user' to 'project_user'.
- Deleting a project was previously open to any member and unaudited; it now needs
  project_admin and writes an audit event. ProjectData.remove no longer drops the
  project from the local cache when the server refuses.

SOP project team from user accounts
- PM/APM/CM/QM and additional team members are pickers over the project's members,
  storing the account id next to the display name. A name from an older SOP with no
  matching account is kept and flagged rather than dropped.
- The WP Creator lists the SOP team first in the Owner picker, and a new package
  defaults to whoever is creating it.

Critical constraints
- SOP constraints carry a Critical flag; buildConstraints() now copies the whole
  definition through to the package (it previously reduced them to names, losing
  description too), and critical rows are marked in the WP form. The email on
  reopen-after-release is wave 3.

Password reset by email
- login.html gains Forgot password and a set-a-new-password view, offered only when
  the server reports email is actually configured.
- Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version,
  sent immediately rather than through the notifications outbox so a reset link is
  never persisted. Identical response for unknown accounts; per-account send
  cooldown; a completed reset clears any login lockout.
- Session and reset tokens are no longer interchangeable.

BIM kill-switch
- New admin Features card with bim_enabled, OFF by default. The SOP creator hides
  the BIM section and the Creator treats every package as install-only while it is
  off; a SOP that already has BIM keeps its data untouched.

Verified with two throwaway-database test scripts: 44 checks on the permissions
matrix and token handling, 22 on the reset flow end-to-end against a local SMTP
sink (real message captured, link extracted and used). Front-end files parse-checked
in headless Chrome. Not yet exercised in a browser against a real login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:48:59 -07:00
39b48055ff Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:51:15 -07:00
afdc815fb4 Store SOPs and Work Packages in the DB (shared across users)
Reintegrates C-West8's "storing data in DB instead of client only"
(commit e5102446 on shared-data) on top of the BIM / per-package work.
localStorage becomes a per-browser cache; the server is authoritative.

- project-data.js: pullProject() hydrates the apps' existing localStorage
  keys from the API on load; pushSOP()/pushWP()/removeWP() write through
  on save/delete. WPs store the whole flat object in `data`, so BIM
  fields, kind, and projectLinks round-trip intact.
- index.html: home pulls the project before showing SOP status; feedback
  loads from /api/comments (server-authoritative, local fallback).
- work-package-suite-app.js: pull-then-restore on boot; completeSOP
  pushes the SOP to the server.
- wp-creation-app.js: save/duplicate/issue/setStatus push; delete/clear
  remove; boot pulls from the server first, then boots off the cache.
- server/app.py: /api/sops and /api/wps take full=true to return the
  data JSON for one-request hydration (list stays lean by default).

Co-Authored-By: C-West8 <125926137+C-West8@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:58:33 -07:00
5ad3ffa58e Per-project access control + UI/feedback/admin refinements
Access control:
- project_members table; non-admins only see/operate on assigned
  projects (enforced across projects, SOPs, work packages — 403 else),
  admins bypass. Creating a project auto-grants its creator access.
- Admin API to get/set a user's project assignments, plus a checkbox
  assignment dialog in the Admin Console user list.

UI / workflow:
- Login page: drop the "Prime Controls" wordmark next to the logo.
- SOP tool: remove emoji icons from buttons and nav tabs.
- Rename "Step Comments" to "Feedback"; the author auto-populates
  (read-only) from the signed-in user.
- Move usage-log viewing to the Admin Console; add an admin card that
  lists all feedback/comments (who, what, page + step, when).
- Sample project name -> "Micron FMCS Install (sample)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:38:56 -07:00
20afb0e565 Add secure username/password login portal
Gate the suite behind a self-contained login (no external IdP):

- User model with bcrypt-hashed passwords; admin/user roles
- /api/auth endpoints: login, logout, me, change-password, and
  admin-only user management (list/create/delete/reset/enable)
- Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure
  cookie; middleware refuses every /api data route without a session
- login.html + auth-guard.js: login page and per-page guard with a
  top-right "name / Admin / Sign out" pill
- Admin Console now gated on admin role (passphrase gate removed) with
  a User administration card
- manage_users.py CLI to bootstrap the first admin
- Rebuilt help.js into a searchable, multi-topic help center
- Local-dev convenience: app serves html/ so the site + API share one
  origin under uvicorn (inactive in the prod container)
- Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:55:54 -05:00
66da5b708a Fix API DB connection: build URL from POSTGRES_* (auto-encode password)
The api crash-looped because DATABASE_URL had an un-encoded special-char
password (@/!), so SQLAlchemy parsed part of the password as the host
("...@db" → name resolution failure).

db.py now prefers building the connection from POSTGRES_USER/PASSWORD/DB via
SQLAlchemy URL.create(), which encodes the password automatically — any
password works with no manual escaping. DATABASE_URL remains an optional
override (still must be hand-encoded if used). docker-compose now passes the
POSTGRES_* vars to the api container; DEPLOYMENT.md updated (incl. a Portainer
env-vars note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:17 -07:00
2bdb65e580 Add seed_demo.py loadable demo project + document both test scripts
server/seed_demo.py seeds a realistic DEMO project (complete SOP + a spread of
Work Packages: issued, gated, multi-discipline master with split instances,
overdue, over-threshold draft) via the API. --clean removes it. DEPLOYMENT.md
documents both smoketest.py and seed_demo.py, including the localStorage caveat
(seeded project shows in the UI picker; seeded SOP/WPs are SQL-only until Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:11 -07:00
e3ef3b0023 Round-1 test feedback + smoke-test script
- API smoke test (server/smoketest.py): stdlib end-to-end check of health,
  projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics,
  comments, and cascade delete. Referenced from DEPLOYMENT.md.

SOP config:
- Constraints: fix custom constraints never appearing — renderStandardConstraints
  no longer clobbers state.constraints; customs render in their own list with
  remove buttons; modal gains a free-text "Add" field.
- Sources: add column headers (Data Type / Location-Platform / URL / Notes);
  preset data types are now fixed labels, "Add Source" creates an editable
  custom row.
- Issuance strategy: add a tooltip + worked examples for each option.
- Remove the "Comment submitted" acknowledgement popup (home + suite); keep the
  commenter name between comments.

WP creator:
- Clearing the last open constraint now offers to mark the package Issued and
  scrolls to the status control.
- Form sections are collapsible (click a section heading to fold it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:04:46 -07:00
3c40b58ff8 Add multi-project support: projects entity, picker home page, project context
Projects become the top-level container; SOPs and Work Packages belong to one.

Backend:
- New projects table + CRUD (/api/projects).
- sops.project_id (FK, cascade) and work_packages.project_id added;
  list/latest/metrics endpoints accept a project_id filter.

Front end (now under html/):
- project-data.js: shared API-first ProjectData adapter with localStorage
  fallback + active-project helpers.
- Home page: removed "About This Suite"; added a Project picker (create /
  use sample / select). Tool cards stay hidden until a project is active and
  carry &project=<id>; hero shows the active project.
- Suite reads ?project, resolves it, shows it in the header, and prefills the
  SOP project fields; passes &project into the WP-creator iframe.
- WP creator stamps projectId onto saved packages.

SOP/WP localStorage is not yet namespaced per project (next step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:39:45 -07:00
4d111d608d Merge branch 'main' into feat/wp-discipline-split-dashboard 2026-06-15 15:28:35 -07:00
d1f0901897 Add discipline strategy, WP sizing, Split-by-Discipline & dashboard
SOP config (Governance step) now sets project discipline policy:
- disciplines list, discipline strategy (single/multi/planner-choice),
  letter instance-suffix style, and a max-hours split threshold.

WP Creator becomes discipline-aware:
- discipline picker; selecting 2+ turns the flat scope into per-discipline
  scope sections, each with its own status (rolls up to least-advanced).
- "Split by Discipline" turns a multi-discipline WP into WP01A/B/C instances
  linked to a kept master (instanceOf/parentNumber/split/children).
- est-hours warning against the SOP split threshold.

New WP Dashboard (header button, home card, ?view=dashboard deep-link):
metrics, status/discipline breakdowns, a gating panel, and a filterable
board with view/edit/issue. Reads localStorage via an API-ready WPData
adapter; masters excluded from counts.

Backend: POST /api/wps/{id}/issue (enforces the constraint gate),
POST /api/wps/{id}/status, GET /api/wps/metrics; work_packages gains
parent_id + issued_at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:29:41 -07:00
ffcaa571d1 Add API to production 2026-06-15 13:46:43 -05:00
a33b777aca Add Python/FastAPI + PostgreSQL backend (Phase 1)
- server/: FastAPI app with SQLAlchemy models for sops, work_packages, comments
- Endpoints for SOP/WP upsert+list+get+delete and comment create+list;
  /api/feedback kept as an alias so the existing client keeps working
- Portable across engines (PostgreSQL prod, SQLite dev fallback)
- requirements.txt, .env.example, and server/README.md (Postgres + systemd)
- NGINX now proxies /api/ to the API (replaces the Power Automate hop;
  comments persist to SQL)
- Rewrite DEPLOYMENT.md for the API + database architecture
- Add .gitignore for venv/.env/sqlite

Phase 2 (wire the client apps to the API) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:58:52 -07:00