SysEngineering

Purchasing Desk handoff

Updated 8 November 2026. Read this before touching the deployed desk, then README.md for how it works.

State: the desk is live and proven end to end in production (test ticket PSA-2616, 25 October). Lenovo: Jennifer’s first real order is on it (K871293632 on PSA-2349, waiting to ship). Amazon is live (#257): the Google Admin rule copies Jennifer’s and Joe’s Amazon order mail to procurement@, and the first real Amazon order’s shipping update was approved from the desk on 2 November (PSA-2643). Okta sign-in, and every Jira change approved by the buyer, made by the desk’s own Atlassian service account. The order ledger, #260’s order prices, Ask the desk and Reports are live (00030-kez, 8 November). Next: the tax status of the six entities missing from Business Ops’ sheet (Reports, below), ABM’s three secrets and its preflight (“Next session: Amazon, then Apple”), asking Robbie which reports she makes today (to add as starters), PSA-2643’s delivery, then roles before Kelly goes on the access list.

Where the desk is going (set 7 November): ROADMAP.md. The order ledger first, then roles, Ask the desk, Robbie’s reports, Apple with ABM, receipts.

Resume here (8 November)

Reports: live since 8 November (00030-kez, #273)

Goal 4’s first cut; how it works is in the README, “Reports”. A Reports tab over PSA and the ledger, built after Adam chose a separate reporting page over tables under Ask’s answers (built and dropped the same day, a78a42d). Ask’s answers now link to the same figures in Reports instead.

Ask the desk: live since 7 November (00028-qux)

Goal 3; how it works is in the README, “Ask the desk”. #272 (main 41b5b65), live as 00028-qux with PROCUREMENT_ANTHROPIC_API_KEY=purchasing-desk-anthropic-api-key:2 (startup: “Ask on (claude-opus-5-5)”). The key is its own: a Claude Console service account purchasing-desk (Developer, not Admin) whose key purchasing-desk-cloud-run is also kept in 1Password. Version 1 of the secret held copied command text (the clipboard held the commands, not the key) and is destroyed.

Order ledger: live since 7 November, views created

Goal 1 of the roadmap; how it works is in the README, “Order ledger”. Adam’s call (7 November): BigQuery in the desk’s own project, separate from Harry’s CSAT, so Ask the desk answers item and price questions from every order the mailbox sees, not only the ones linked from the desk.

Resume here (3 November)

Amazon is live; first real Amazon order shipped from the desk (2 November)

Order prices: built 3 November on feat/procurement-order-prices, not merged

Amazon’s shipped and delivered emails print no price, so an order whose confirmation never reached procurement@ showed “—” (PSA-2643). Buyers type the cost into the ticket by hand; in the last 180 days only 3 of 253 Amazon tickets have none (PSA-1482, PSA-1120, PSA-997).

Ask the desk (CIO, 3 November): mocked, not built

Moved to ROADMAP.md, goals 1 (items on the ticket) and 3 (Ask the desk).

Resume here (29 October)

Amazon: built 29 October; next a PR, the deploy and a pilot

Built (29 October, night), all on the branch, uncommitted:

Next: commit; one PR (the Amazon build, its UI wording and docs together); merge; the usual deploy (production is still 00022-goq); then a pilot on one real Amazon order with Jennifer or Joe. The rule’s first copy arrives with their next Amazon order: check procurement@ has it, and that the desk reads it (the copy must still pass the sender check; drop it in if in doubt).

Before the build (how we got here). The vendor framework (Step 0 below) and these notes are on main through #255, merged but not deployed: production is still 00022-goq. The framework changes nothing on its own, so it ships with the Amazon build’s deploy (a deploy of main before then is safe). The Amazon build starts on a new branch from main (“Picking this up on another machine” below, <topic> = amazon) and goes out as one PR with its UI wording and docs. That branch exists (worktree .worktrees/procurement-amazon, not pushed yet) with the part that needs no samples: reading Amazon’s PSA titles (“What Amazon changes” below). Amazon’s order emails reach only the buyer who placed the order: procurement@ gets Amazon’s promotions and the Prime billing statement, and Amazon Business has no shared notification list (Adam looked on 29 October; Amazon’s documentation agrees). So a Google Workspace rule copies them. Adam is not a super admin in hillspire.com, so a coworker who is creates it.

  1. The rule (a hillspire.com super admin). Saved 29 October, 7:44 PM, by Adam’s director, checked against his screenshots: as below, plus Google’s two defaults on the added recipient (“Do not deliver spam to this recipient”, “Suppress bounces from this recipient”), both wanted. Admin console › Apps › Google Workspace › Gmail › Compliance › Content compliance. If rules already exist, click Add another rule; never edit an existing one.
    • Description: Purchasing Desk: copy Amazon order mail to procurement@.
    • Email messages to affect: Inbound only.
    • If ALL of the following match the message, two Advanced content match expressions: Location Sender header, Contains text amazon.com; and Location Any envelope recipient, Matches regex (jshon|jwen)@hillspire\.com (no anchors, in case Google tests the recipients as one string; Jennifer and Joe; Joe’s address is confirmed by Apple’s shipping notice, which is addressed to him).
    • If the above expressions match: Modify message › Also deliver to › Add more recipients › Add › procurement@hillspire.com. Tick nothing else: not Change envelope recipient (that redirects, and the buyers would stop getting their mail), no route change, no spam bypass, no headers or subject prefix. Not Reject or Quarantine.
    • Save at the top-level organizational unit; the recipient expression is what limits it to the two buyers.

    It only adds a copy, like a BCC: Jennifer, Joe, support@ and everyone else receive exactly as before, and nobody sees procurement@ on the message. Undo by disabling or deleting the rule. Tell Jennifer and Joe their Amazon mail is copied, in case a personal Amazon account uses the work address. The live desk reads only Lenovo’s senders, so nothing changes on it until the Amazon build ships.

  2. Check it. On Jennifer’s or Joe’s next Amazon order (minutes; Google allows up to 24 hours), they get their email as usual and procurement@ has a copy. Only mail after the rule is copied: an order placed before it brings its shipped and delivered emails, not its confirmation.
  3. Samples (Adam). Done 29 October: Joe’s three emails for one order. They need not wait for the rule: Jennifer or Joe can download their own copies (⋮ › Download message, never Forward, which breaks the sender check), and the desk’s drop-in path can pilot with them before the rule exists. In procurement@, open each and ⋮ › Download message into C:\Users\adams\Downloads\amazon-samples\: an order confirmation (several items if possible), a shipped email (one package, and a split one if it comes), a delivered email, and a cancellation if one happens. While there, Apple’s order confirmation for AAA6914904, which should show whether Apple’s emails carry prices.
  4. Build (Claude, in the worktree). Done 29 October (above). Copy the samples into their own scratch folder and read them as untrusted input (python -I, path as an argument). Then: server/amazon-mail.mjs with invented fixtures and tests; the adapter in vendors.mjs with Amazon’s verified senders from the samples, orderPattern ^\d{3}-\d{7}-\d{7}$ and a mailQuery whose after: is the day the rule went live; check the copies still pass the desk’s DKIM and DMARC check; the vendor-neutral UI wording and the Amazon title cases (Step 0 and “What Amazon changes” below); then one PR, the usual deploy, and a pilot on one real order with Jennifer or Joe.

What is live

Jennifer’s first real order (K871293632, PSA-2349)

Open items, in order

  1. The assignee write is unproven (PSA-2349 was already assigned). The Purchasing Desk account may lack “Assign issues” in PSA; if so the link still lands, the ticket stays unassigned, and Cloud Run logs desk.assign_failed. Check on the first link made from the desk.
  2. LenovoPRO notifications to procurement@hillspire.com (“Setup”, step 4), per buyer. Joe’s PSA-2440 names Lenovo order K871295219 but the desk never saw it, so his emails still go to his own inbox. Until fixed, buyers drop emails in.
  3. Housekeeping now that a real order is through:
    • Remove the four roles/iap.httpsResourceAccessor grants (gcloud iap web remove-iam-policy-binding --resource-type=cloud-run …); they only served the IAP pilot rollback.
    • Revoke Adam’s personal read token behind purchasing-desk-jira-api-token (only 00007-les used it).
    • Ask Jennifer what she’d change in the comment, and whether anyone besides the buyer ever approves a purchasing request (decides “In Progress on link”).
  4. Atlassian billing: whether the service account’s agent role uses a paid seat.
  5. ITPROJ-1415: post the first-real-order update (first person, Adam’s voice, concise).
  6. Merge the deploy-from-main pipeline (“Goal” below) once a few real orders have gone through.

Next session: Amazon, then Apple (decided 28 October)

Adam wants the next two big vendors built carefully, Amazon first, then Apple. Most PSA tickets are Amazon or Apple (see the open queue: Amazon (111-…), Apple (AAA…)). Same rules as Lenovo, unchanged: link never create, every Jira write previewed and approved by the buyer, fingerprinted, read-only sources.

Step 0, the framework (no behaviour change): built 28 October, merged in #255, not deployed yet. server/vendors.mjs lists the vendors; each parser module exports an adapter { id, name, senders, mailQuery, orderPattern, parse }, and Lenovo is the first. The desk parses every email through parseVendorEmail (the From address picks the parser, which still verifies DKIM and DMARC), the Gmail query is the adapters’ mailQuery terms (one vendor gives exactly the old query), Jira’s order search accepts any vendor’s order number and only [A-Za-z0-9-], and the vendor name comes from the adapter. The facts each parser returns are unchanged. All 98 existing tests pass unchanged (103 with the registry’s own), and the 37 UI checks pass. Done on the Amazon branch (29 October): the page’s “Lenovo” copy now names the order’s own vendor, or no vendor where it covers all of them (the drop zone, empty states, refusal messages; “LenovoPRO” became “check the order with "), and the ticket picker favours tickets naming the order's vendor. Still Lenovo-only: `orders.mjs`'s date reading, which knows Lenovo's "Get it by" wording and falls back to the raw text for anything else; Amazon's wording comes from the samples.

Amazon (Amazon Business; Adam has access to the account).

Decided 28 October: emails, exactly like Lenovo. The Amazon Business API waits for the receipts idea. 29 October: order emails go only to the buyer who placed the order, and Amazon Business has no shared notification list, so a Google Admin rule copies them to procurement@ (“Tomorrow (30 October)” above).

  1. Discovery, read-only, before code:
    • Real .eml samples from Amazon Business: order confirmation, shipped (one and several packages), delivered, a cancelled or partly cancelled order, and an order with several items. Download them like the Lenovo ones; never commit them. Tests use invented copies.
    • Where the emails go today: answered 29 October, each buyer’s own inbox only; mailbox-first through the Google Admin rule above.
    • Whether the Amazon Business APIs (Order History / Reconciliation, through Amazon’s developer registration and an admin’s authorization) are worth it later: orders and shipments without email, and receipts for Concur. Emails first: same pattern as Lenovo, no new access.
  2. What Amazon changes: order numbers like 111-1234567-1234567 (three groups, digits only), several orders per ticket (Amazon (111-…, 114-…) titles are common), one order shipping in several packages, and a real “Delivered” email (the desk could pre-fill the delivery date; still approved). Checked against live Jira 28 October: summary ~ "114-7099723-7304231" finds the hyphenated number as one token (PSA-2638, and numbers after CANCELLED - or a semicolon too); a near-miss sharing two groups finds nothing, and summary ~ "114" finds nothing. The existing plain query works; no phrase query needed. Real Amazon titles vary (PSA, 14–28 October): prefixes 111-, 112-, 113-, 114-; several orders per group separated by a comma, semicolon or &; vendors separated the same three ways; leading spaces; CANCELLED - 111-… inside the group (PSA-2453, PSA-2437); a title with no colon after the group (PSA-2587); one with a stray ( (PSA-2424). Built 29 October on the Amazon branch, decided by Adam: one title reader, server/title.mjs, shared by the server and the page (it replaces src/title.js, which had drifted from the server’s copy). A CANCELLED - … number still names its ticket but is not open, so the close warning leaves it out. A title with no colon keeps her format: leading Vendor (…) groups count when every reference looks like an order number (5+ characters, 4+ digits, so “MacBook Pro (2024)” stays a name), and a new order joins its vendor’s group with her own separator (, ; &). Run over the 100 newest Amazon titles: 91 were right before, all 100 now. 107 tests and the 37 UI checks pass.
  3. Build: the adapter and its tests, then the Gmail query widened to Amazon’s verified sender and fixed subjects, then a pilot on one real Amazon order with Jennifer or Joe.

Apple (Apple Business Manager API; a coworker gave Adam an API key).

Order: framework, then Amazon discovery, then Amazon build and pilot, then Apple discovery, then Apple. Couple the steps into as few PRs as ship sensibly (Adam, 29 October: every PR notifies his coworkers’ channels): the Amazon build, its UI wording and its docs go out as one.

Ideas Adam wants next (decided 26 October; take it slow and build each well)

Done 28 October: the Today strip, package-style progress and late badges (#242).

Queued:

The rest of the 26 October list, all wanted:

Picking this up on another machine

git clone git@github.com:Hillspire/SysEngineering.git   # or git pull on main
cd SysEngineering
git worktree add .worktrees/procurement-<topic> -b feat/procurement-<topic> origin/main
cd .worktrees/procurement-<topic>/integrations/procurement_workspace
npm ci
npm test            # 191 unit tests
npm run dev         # http://127.0.0.1:4317, invented data, click "Sign in"
npm run verify      # 90 checks in headless Chrome (needs Chrome installed)
gcloud auth login   # only for deploys and logs; project hillspire-procurement
gh auth status      # PRs; the token needs Hillspire org SSO authorized

Amazon Business API (next, for carrier tracking)

Amazon’s Package Tracking API returns the carrier and its tracking number: getPackageTrackingDetails, GET /ab-tracking/2025-07-02/orders/{orderId}/shipments/{shipmentId}/packages/{packageId} (na.business-api.amazon.com), response packageTrackingDetails.carrierDetails with localizedCarrierName and carrierTrackingId (both optional). The shipped email’s tracker link already carries orderId and shipmentId; whether its packageIndex is the API’s packageId is to check (the Orders API’s getOrder lists packages if not). With it, the ship preview’s carrier box fills itself.

Access is a process, partly Amazon’s: an Amazon Business account on the Solution Provider Portal, a developer profile requesting the role for this API (Amazon reviews it, with identity verification), an app client (business entity: Amazon Business) whose Login with Amazon credentials an Amazon Business admin authorizes. Same rules as ABM: the credentials go straight into Secret Manager in hillspire-procurement, only the runtime reads them, the client is GET only with a test that fails the build otherwise, and the narrowest read role. The same access later serves receipts (Reconciliation API) for Concur. Docs: docs.business.amazon.com, “Package Tracking overview” and “Onboarding overview”.

Known edges (accepted)

Live deployment

Setting Current state
URL https://purchasing-desk-pilot-343992350788.us-central1.run.app (also …-6efoouk45q-uc.a.run.app)
Project hillspire-procurement (343992350788), us-central1, service purchasing-desk-pilot
Revision purchasing-desk-pilot-00030-kez, 100%, from main 4ace109 (#273, image pilot:20261108-main-4ace109), deployed 8 Nov
Image …/purchasing-desk/pilot@sha256:7b7748bbc36ead1433e8d17f54a5e2dfdce3ec1bf9cf66718f6850c19fa7cb37
Access IAP off, allUsers invoker, Okta OIDC in the app: landing page public, everything else needs Okta
Identity Runtime purchasing-desk-runtime@, build purchasing-desk-build@, mailbox purchasing-desk-mail@
Jira Service account Purchasing Desk, purchasing-desk-jira-desk-token:1 (scoped), writes on
Bounds min 0, service max 1, concurrency 8, 1 CPU, 512 MiB
Rollback Route to 00028-qux (Ask, no Reports); never before 00016-kux (the 201 bug). IAP pilot: “Cutover history” below

Users: aschembri@, rparas@, jshon@, jwen@hillspire.com. Custom domain purchasing.hillspire.com stays on hold (DNS at Network Solutions, ownership unclear).

Revisions on 25–26 October: 00011-cah (cutover, old look), 00014-nan (#225/#226, had the 201 bug), 00016-kux (#228), 00018-poz (#229). 28 October: 00020-now (#242), 00022-goq (#248). 30 October: 00024-duj (#257). 7 November: 00026-san (#270, #260; checked: the tag URL gave 308 on / and 403 on /api/session, the startup line read “mailbox on, Jira writes on”, and signed out on the live URL / is 200, /app.js and /api/orders are 401). Then 00028-qux (#272, Ask; the same checks, the startup line ending “Ask on (claude-opus-5-5)”, and /api/ask 401 signed out). 8 November: 00030-kez (#273, Reports; the same checks, and /api/report 403 on the tag URL, 401 signed out).

Routine deploy (from an up-to-date main only)

Only merged code ships: build from a clean checkout of origin/main (a detached worktree works), never from a feature branch or a dirty folder. Env values and secret versions live on the service, so a routine deploy changes only the image; add --update-env-vars only for a new setting. Promoting needs Adam’s explicit go.

P=hillspire-procurement; R=us-central1; S=purchasing-desk-pilot
cd integrations/procurement_workspace
npm ci && npm test
gcloud meta list-files-for-upload .   # allowlisted sources only; every src/ file must appear
IMAGE=us-central1-docker.pkg.dev/$P/purchasing-desk/pilot:$(date +%Y%m%d)-main-$(git rev-parse --short HEAD)
gcloud builds submit . --project=$P --region=$R --config=cloudbuild.yaml \
  --service-account=projects/$P/serviceAccounts/purchasing-desk-build@$P.iam.gserviceaccount.com \
  --gcs-source-staging-dir=gs://hillspire-procurement-build-source/source --substitutions=_IMAGE=$IMAGE
DIGEST=$(gcloud artifacts docker images describe $IMAGE --project=$P --format='value(image_summary.fully_qualified_digest)')
# 1. Stage with no traffic.
gcloud run deploy $S --project=$P --region=$R --image=$DIGEST --no-traffic --tag=candidate
# 2. Check: the tag URL answers / with 308 and /api/session with 403, and the log shows
#    "Purchasing Desk started: Okta sign-in, mailbox on, Jira writes on."
# 3. On Adam's go: promote BY NAME and drop the tag.
gcloud run services update-traffic $S --project=$P --region=$R \
  --to-revisions=<new revision>=100 --remove-tags=candidate
# 4. Signed out, on the canonical URL: / is 200, /app.js and /api/orders are 401.

Jennifer’s first order

  1. She raises or picks the PSA ticket as today and moves it to In Progress, then places the Lenovo order (procurement@ as the notification email once step 4 is done; until then she downloads the emails from Gmail and drops them on the desk).
  2. Within a minute of the confirmation email the order is under Needs you as “Needs a ticket”. She clicks Link to a PSA ticket, picks the ticket, checks the preview (title, totals, assignee, comment) and approves. The order moves to Waiting.
  3. The shipping email brings it back to Needs you: approve, and the ticket moves to Shipped with the tracking link. It waits again as “Arriving by …” until Lenovo’s date passes; “Arrived early? Close it” works any time.
  4. Delivery: pick the date, approve. The desk posts DELIVERED and closes the ticket. Nothing closes on its own.

Check in Jira afterwards: title, Total Order Cost/Items, assignee, the public comments, status, and that the history shows the Purchasing Desk account. Reporting: project = PSA AND issue in updatedBy("712020:641e70b0-d5ba-4215-895a-bc029ea1622f") lists every ticket the desk changed.

Facts the code depends on (checked 20–26 October)

Setup only Adam can do

  1. Okta app (done 22 October). OIDC web app Purchasing Desk, Authorization Code only, redirect https://purchasing-desk-pilot-343992350788.us-central1.run.app/auth/callback, Login initiated by: App Only, client secret, Require PKCE. Client ID 0oa190iuv999gNscw698; group purchasingdesk-app-user.
  2. Atlassian service account (done 24 October). Purchasing Desk, JSM access, PSA’s Service Desk Team role. Scoped token with only the classic scopes read:jira-work and write:jira-work, one year at most, in purchasing-desk-jira-desk-token.
  3. Gmail delegation (done 25 October). hillspire.com Admin console › Security › API controls › Domain-wide delegation: client ID 108320925923961802593, scope https://www.googleapis.com/auth/gmail.readonly. Google cannot limit this to one mailbox; the code does (subject fixed to procurement@, query fixed to Lenovo’s sender).
  4. LenovoPRO account (open): order notifications to procurement@hillspire.com.
  5. Amazon order mail to procurement@ (open, needs a hillspire.com super admin): the Gmail content compliance rule in “Tomorrow (30 October)” above.

Secrets go straight into Secret Manager, never into chat or files. On Windows from Claude Code’s ! prompt (Git Bash, where /dev/clipboard comes back empty): powershell.exe -NoProfile -Command "[Console]::Out.Write((Get-Clipboard -Raw).Trim())" | gcloud secrets versions add <name> --project=hillspire-procurement --data-file=-. On a Mac, pbpaste | …. If gcloud says “Reauthentication failed”, run gcloud auth login first; nothing is written when that error appears.

GCP resources (prepared 21 October)

Cutover history (25 October)

Okta replaced IAP: traffic moved to the Okta build while IAP still guarded it (00009-gef), Adam tested sign-in, Orders and Requests through IAP, then IAP came off and allUsers became invoker (00011-cah). Signed-out checks passed: / 200, /app.js and /api/orders 401, the second URL 308 to the first.

Rollback to the IAP pilot (only if the Okta desk must go dark): IAP back on (gcloud run services update $S --iap), remove the allUsers invoker, route to 00007-les. That revision requires IAP’s signed header, so with IAP off it refuses everyone rather than opening.

Goal: deploy from main, like Harry

Development continues here for a long time, so deploys move to a pipeline (Adam, 21 October), once a few real orders have proved the manual steps. Model it on .github/workflows/harry_deploy.yml and harry_agent_tests.yml (Harry HANDOFF, “Moving to merged to main = deployed”):

  1. On every PR touching integrations/procurement_workspace/**, run npm test, npm run build and the prettier check. Add npm run verify if the runner’s Chrome works.
  2. On merge, run the tests, build with cloudbuild.yaml and the purchasing-desk-build@ account, stage a no-traffic tagged revision, check it, promote it, confirm the live revision runs that commit, remove the tag.
  3. Pause switch: a repo variable like HARRY_AUTO_PROMOTE.
  4. Announce and record: #hillspire-dev-updates and the Confluence deployment log (integrations/deploy_log/).

Differences from Harry to settle: