Skip to content

Enhancement: Court Planner + Recommendations workflow (Planner, Court Report, Herald Script, Recs Manager, notifications, pass-to-local) - #490

Open
baltinerdist wants to merge 140 commits into
amtgard:masterfrom
baltinerdist:feature/court-planner
Open

Enhancement: Court Planner + Recommendations workflow (Planner, Court Report, Herald Script, Recs Manager, notifications, pass-to-local)#490
baltinerdist wants to merge 140 commits into
amtgard:masterfrom
baltinerdist:feature/court-planner

Conversation

@baltinerdist

@baltinerdist baltinerdist commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Court Planner + Recommendations workflow

A complete workflow for planning royal courts and managing award recommendations.

Court Planner

  • Plan kingdom/park courts: schedule, add awards + contributing artisans, drag-reorder, scroll/regalia tracking, and grant directly from the plan
  • Stage → finalize: granting at court captures giver/reason/date/rank and marks the line staged; it does not touch the permanent record. A single Finalize (folded into Complete Court) batch-commits every staged grant, so undo before finalize is free. The idempotency key is the court line — an atomic staged→given claim means a line commits at most once, and finalize is throw-safe (a failed AddAward reverts that row to staged and the court will not complete until it is clear)
  • Run vs Plan is a stored court mode. Run mode shows a granted row as Given with an Undo for the one-tap ceremony flow; Plan mode keeps the explicit staged framing. Both share the identical pipeline
  • Optimistic concurrency (row_version) plus a presence heartbeat, so several reeves can run one court live without silently clobbering each other
  • Court Report (public): per-kingdom/park court history with awards given
  • Herald script generation for reading court
  • Responsive/mobile layout for running court from a phone

Recommendations Manager

  • Dedicated management page, server-side paginated (500-row batches + infinite scroll), grouping parallel recommendations into one row per (recipient, award, rank) cluster
  • Server-side filter/sort; Park and Rank columns; CSV export of the full filtered set
  • Grant Award modal — pre-filled from the recommendation, with rank pills (green = already held), officer quick-pick chips (Monarch/Regent), and a park/kingdom/event location picker
  • Granting a recommendation that already sits on a court plan is reconciled server-side in the same request, scoped to courts the officer can manage and never to a completed court, so a later finalize cannot re-grant it. The officer's "leave on court" / "remove from court" choice is carried as CourtAction and applied in the domain layer
  • Snooze-to-next-monarchy, pass-to-local delegation, and second/support a recommendation
  • Anonymous recommendations (recommender masked per viewer, never cached across viewers)
  • In-app notifications to advocates when a recommendation is granted

Architecture

DB work lives in system/lib/ork3/ (Court + Report libs); controllers stay thin and reach the domain layer through orkui/model/model.Court.php.

Migrations

Ten migrations under db-migrations/ — court tables, stage/finalize + grant-safety columns, recommendation snooze / pass-to-local, the notification table, and an idempotent repair for ork_session.user_agent/ip. Apply before deploy.

The session repair is unrelated to court but ships here because the branch surfaced it: the device-metadata columns were added by editing the original 2026-07-13-add-ork-session-table.sql in place. That file leads with CREATE TABLE IF NOT EXISTS, so re-running it on a database built from the pre-metadata version adds nothing, CreateSession() then inserts columns the table does not have, and every password login fails. Fresh installs were never affected.

Review

The branch went through two rounds of adversarial review (hostile multi-agent panels; findings kept only if they survived an organized attempt to refute them), the second run after a layering refactor moved the Court Planner behind a model. Everything found in both rounds is fixed.

Round 2 — the ledger and the public record

  • One honor could reach the permanent record many times. The add-to-court dedup keyed on the recommendation id, but the honor is the cluster (recipient + award + rank). Several people recommending the same person for the same award is routine — production data has clusters of fifteen — and the picker emitted a row per recommendation, so each added its own court line and each finalized independently. The dedup now matches the cluster, the picker groups by honor with a support count, and finalize refuses to commit the same honor twice.
  • The cross-path reconcile rewrote courts it had no business touching. Its UPDATE matched a global recommendation id or the cluster key with no court join — no scope, no authority check, no court-status check. Because "Leave As-Is and Close" legitimately leaves planned lines on a completed court, a grant months later flipped a finalized court's line to given and published an honor on the login-free Court Report for a ceremony where it was never announced. Candidates are now filtered per row through canManage against that row's own court, and completed courts are excluded.
  • Anonymous recommenders were unmasked to the wrong people. Masking was folded into a response cached for 300s under a key with no viewer dimension, so whoever warmed the cache decided what everyone saw. Masking now happens per request — which also restores the ability of someone who filed an anonymous recommendation to edit their own reason.
  • A withdrawn grant could still be committed (un-stage left the giver and citation behind while staged stayed client-settable), Run mode never delivered the one-tap grant the mode exists for, and a dead status-sync block was removed.

Round 1 — the grant seam

  • The Recs-Manager grant left the recommendation unresolved and invited a retry that would double-grant; internal officer notes could become the public citation; clients could force a line to given without linking an award id; concurrent adds returned the wrong row id (LAST_INSERT_ID vs a "highest id" re-query); a double-click could put one recommendation on two court lines; the award autocomplete could submit a different award than the one displayed; reordering under the Printing List filter corrupted the running order; and the CSV export was open to formula injection via player-written text.

Verification

PHP lint clean across changed files; ork-db migration coverage passes; end-to-end HTTP + DB checks on every fix and on grant (both court actions), stage/finalize, add/dedup, snooze, pass-to-local, dismiss, pagination, filters/sorts and export. Not covered: PHPUnit was not run (no composer in the dev container), and no concurrency was exercised — the multi-reeve and atomic-claim guarantees are reasoned from the SQL shape, not reproduced under load.

🤖 Generated with Claude Code

@baltinerdist

Copy link
Copy Markdown
Contributor Author

Updated with the Court Planner grant-safety + workflow/UX implementation (commit 748e3df6):

Records integrity

  • Single idempotent grant sink (Court::commitStagedAward) — court-line identity is the idempotency key; Player::AddAward now returns the real inserted id (date-heuristic removed); throw-safe finalize (revert-on-throw, re-runnable).
  • Server-side cross-path reconcile so a Recs-Manager grant can't be re-granted at finalize; update_court_status rejects complete; remove/skip/setStatus guard given rows.

Concurrency

  • ork_court_award.row_version optimistic lock (stale → non-destructive reload), full-field heartbeat reconcile, presence roster + honest sync indicator.

Workflow / mobile / a11y

  • Run-mode "Grant" + auto-finalize on Complete; walk-on adds while published; inline un-skip; mobile stacked-card layout so Grant/Skip are reachable on a phone; a11y additive pass; native confirm/alert removed.

Ceremony polish

  • WCAG-AA rank-pill ramp; functional status (Cancelled → Skipped); expanded-row highlight; flags/type tooltips; ad-hoc add split into Add Award / Add Title with typeable autocompletes grouped exactly like the player Add Award modal (rank pills replacing the number box).

Verified end-to-end against a live instance (6/6 functional + 4/4 fix re-tests; all data-integrity SQL invariants at 0). Migrations: 2026-07-11-court-stage-finalize.sql, 2026-07-12-court-grant-safety.sql.

🤖 Generated with Claude Code

Avery Krouse and others added 29 commits July 18, 2026 16:24
- New Court class, Court controller, and CourtAjax controller
- Court detail and list templates
- DB migration for court planner table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mendations

- Court Planner tab added to Kingdomnew profile (visible to canManage users only):
  loads CourtList/UpcomingEvents from controller, inline card list with status
  badges and award counts, Plan a Court modal using kn-* CSS classes
- Anonymous toggle on all recommendation modals (Playernew, Kingdomnew, Parknew):
  checkbox stores mask_giver=1 in ork_recommendations; submitter fields are
  nulled in PlayerAwardRecommendations for non-ORK-admin callers; ORK admins
  see name with (anon) label; CallerUid + CallerIsOrkAdmin wired through all
  three controllers and the Park/Kingdom/Player AJAX handlers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Court Planner tab on Kingdom and Park profiles (Monarch/Regent/PM access)
- Court detail page: create/manage courts, add awards, reorder, track scroll/regalia status
- Award recommendations: anonymous rec support, snooze functionality, rec enhancements
- Print court script: two-column table layout with rec reason and recommender attribution
- Fix: Court detail template path (HTTP_TEMPLATE → relative path)
- Fix: Print script overlay moved to body level so print CSS selector works correctly
- Fix: Authorization switch fall-through missing break for AUTH_EDIT case
- Fix: Password reset email copy and expiry duration
- DB migrations: snooze-to-recommendations, court-rec-enhancements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Award row layout (Court_detail.tpl):
- Replaced rigid 6-column CSS grid with 2-line flexbox layout
- Line 1: bold persona + park abbreviation + note icon
- Line 2: award name + rank + compact icon-only flags + tracking icons
- Removed type badge (left border color already communicates title/ladder/award)
- Pass-to-Local and From-Rec flags reduced to 20px circle icons with tooltips
- Updated both PHP server-rendered markup and JS cpAppendAwardRow() to match

Plan a New Court modal (Kingdomnew_index.tpl, Parknew_index.tpl, revised.css):
- Fixed modal rendering outside overlay (missing position:fixed CSS)
- Aligned field classes to match existing modal pattern (kn-acct-field, kn-modal-close-btn)
- Added scoped CSS for both kingdom and park new-court modals

revised.js syntax fix:
- Repaired two unclosed IIFE/addEventListener blocks at end of file that prevented
  the court planner tab from responding to clicks

DB migration (local only):
- Created ork_court, ork_court_award, ork_court_award_artisan tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reverse-engineered PRD covering: ork_court / ork_court_award /
ork_court_award_artisan tables, recommendation snooze (regnum-scoped
auto-expiry), award context preservation (court_award_id +
source_reason on ork_awards), anonymous recs (mask_giver wired
end-to-end), planning + run-of-show + post-court workflows. Flags the
parallel ork_recommendation_support vs Mask branch's
ork_recommendation_seconds collision that needs resolution before
either can merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Spreadsheet layout for the Order of Court list — sticky column header,
  CSS-grid rows, density toggle (Cozy / Comfortable / Compact) persisted
  to localStorage, sidebar collapse rail, row #'s kept in sync on
  reorder/add/remove.
- Comprehensive dark-mode coverage on both Court_detail and Court_list:
  status bar, sidebar cards, modals, type chips, tracking icons, status
  badges, recommendation modal, expand area, autocomplete dropdowns.
  Inline-styled pills/buttons refactored to classes so theme overrides
  actually apply.
- "Add from Recommendations" modal now delegates to
  Reports::PlayerAwardRecommendations, gaining Master-peerage cascade,
  custom-award carve-out, award_id cross-check, snooze awareness, age,
  seconds, and anon-rec masking. Modal gets 4 view filters
  (Open / All / Snoozed / Already Has), inline age badges, seconds count,
  on-another-court warning, and per-row "Already Has / Covered by Master"
  context. Each row collapsed from ~5 lines to 2 (header + reason).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standalone spreadsheet-style tool (Recommendations/manage/{kingdom|park}/{id})
consolidating admin rec actions (Grant Now, Dismiss, Snooze, Add to Court) off
the inline profile tab. Reuses existing AJAX endpoints + Court Planner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 bite-sized tasks: route scaffold, getRecommendationCourtMap, controller
data, spreadsheet grid, expand rows, sort/filter, selection+bulk, snooze/
dismiss, Grant Now, Add to Court, inline-tab migration, dark-mode/QA pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kingdom/Park profile recs tabs: add authority-gated "Manage Recommendations"
button; remove inline grant/snooze/dismiss/add-to-court controls + the now-
orphan add-court overlays (kept the Enter-Awards award modals; community
+1/second controls retained). revised.js handlers left inert (delegated/null-
guarded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baltinerdist and others added 15 commits July 18, 2026 16:25
Adds the rows() action + rmParkMap() helper to Controller_Recommendations
and the recommended_awards_page() model passthrough. The endpoint renders
one 500-row batch of shared _rm_row.tpl partials as JSON. Also fixes a
MariaDB correlation bug in Report::PlayerAwardRecommendationsPage's support
subquery (outer alias not visible through a UNION-in-FROM derived table)
that made every page return zero rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the count SQL

The page/count query approximated AlreadyHas with kacount/awcount only, so the
'Showing N of M' total read a few high (and ator a few low) for ladder recs
covered by a Master peerage. Inject the GetLadderMasterMap ladder->master pairs
as a derived table + correlated EXISTS so the SQL AlreadyHas matches the PHP
hydration exactly. Verified park 76 open now rows==total (272==272).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the plain one-word data-tip on the snooze button with the rich
.rm-passlocal-tip pattern so it can render a bold title plus description:
"Snooze to Next Monarchy" / "Temporarily dismiss this recommendation until
either the Monarch or Regent officer at this level changes." Snoozed state
shows an Unsnooze counterpart. Right-anchored + dark-mode variant; JS swap
targets the icon span so it no longer clobbers the tooltip markup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt Award modal; + Court/Recs polish

Recommendations Manager
- CSV export endpoint streaming the full filtered/sorted set (Recommendations/export)
- Server-side Rank sort + sortable Rank column header
- Split Park into its own column (abbreviation); Rank into its own column (pill without the "Rank" word)
- Grant Award modal (replaces insta-grant — never grants without confirmation):
  pre-filled recipient/award/rank/note, JSON PlayerAjax grantaward endpoint, court-plan
  reconciliation folded in, resolves the cluster on success
- Grant modal parity with the regular award modal: rank pills (green = already held),
  officer quick-pick chips (Monarch/Regent), "Given at" park/kingdom/event location picker

Court Planner + Recs polish
- Security: add_award scopes KingdomAwardId to the court's kingdom (IDOR)
- grant_award atomic claim (no double-grant); reorder_awards single CASE UPDATE (no N+1)
- All CourtAjax mutations moved into the class.Court lib layer (no raw $DB in the controller)
- controller.Court lookups + hero heraldry routed through the lib (restores cache-buster)
- Court_detail: error handling on post()/cpSaveOrder, native confirm()/alert() → in-product
  dialogs, batch add-from-rec no longer marks failed adds as succeeded
- class.Report: expose HeldRank; class.Court do-while → while (read-after-Next fix)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design spec for splitting grant (stage) from commit-to-record (finalize),
adding a stored run-vs-plan court mode, a two-tap pre-filled grant modal,
per-row undo, drag-and-drop reorder, a live multi-manager heartbeat, a
prepopulate-skipped-from-last-court banner, and a complete-court modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GDxMDhZUEMepFQ9hMidnpJ
Six-expert review roadmap: S1 idempotent grant sink + QW1-9 + S2 mode-driven
workflow + S3 responsive mobile + S5 optimistic concurrency/presence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXQ2YePHm59VSzozsxbNXJ
…kflow, mobile, collaboration & ceremony polish

Implements the stage/finalize + grant-safety specs on top of the Court Planner.

Grant safety / records integrity:
- Single idempotent commit sink (Court::commitStagedAward); court-line identity is
  the idempotency key. Player::AddAward returns the real inserted id (findRecentAwardId
  date-heuristic removed); throw-safe finalize (revert-on-throw, re-runnable).
- Server-side cross-path reconcile (reconcileGrantForRecommendation, exact rec id OR
  cluster key) so a Recs-Manager grant can't be re-granted at finalize.
- update_court_status rejects 'complete' (only finalize completes a court);
  remove/skip/setStatus guard 'given'; update_award no longer writes lifecycle status.

Concurrency / collaboration:
- ork_court_award.row_version optimistic-lock token (409/stale -> non-destructive
  reload); full-field heartbeat reconcile (add/remove rows); presence roster +
  honest sync indicator; silent background poll.

Workflow / UX / mobile / accessibility:
- Run mode: 'Grant' vocabulary + auto-finalize on Complete; walk-on adds while
  published; inline un-skip. Mobile: overflow-x wrapper + stacked-card layout so
  Grant/Skip are reachable on a phone. a11y additive pass (aria-live, labels,
  contrast, tracker glyphs, dialog roles); native confirm/alert removed.
- Rank-pill contrast ramp reworked to WCAG AA (uniform saturated + white text).
- Functional status (inert manual dropdown removed; Cancelled -> Skipped);
  expanded-row highlight; hero breadcrumb tidy; flags/type instant tooltips.
- Ad-hoc add split into Add Award / Add Title with typeable autocompletes grouped
  exactly like the player Add Award modal (new Model_Award::fetch_award_option_groups),
  rank pills replacing the number box.

Migrations: 2026-07-11-court-stage-finalize.sql, 2026-07-12-court-grant-safety.sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXQ2YePHm59VSzozsxbNXJ
@baltinerdist
baltinerdist force-pushed the feature/court-planner branch from 748e3df to d32dc7c Compare July 18, 2026 20:26
baltinerdist and others added 2 commits August 3, 2026 10:30
Full mobile-review pass over the Court Planner, measured live at 320/390/768px
in light and dark mode. The module previously had only two media queries (640px
and 800px, both off the house cluster) and no real phone layout; because <html>
is overflow-x:hidden and no ancestor in the court-detail chain is a scroller,
overflowing content was destroyed rather than merely off-screen.

Layout / overflow
- Re-anchor breakpoints to the house cluster: 800px -> 768px, 640px -> 600px.
  The module now has exactly two, 600 and 768.
- .cp-section-header wraps at <=768px; .cp-main-content no longer sizes to the
  button row's max-content, which was setting a 419px floor and clipping the
  scroll/regalia/chevron cells off every award row.
- Hero stacks at <=600px. The court name rendered at literally 0px wide (nowrap
  + a flex-shrink:0 actions column); it now wraps and the run-mode workflow
  buttons no longer sit 78px past the right edge.
- .cp-expand-grid collapses to one column, so Pass to Local and Regalia Maker
  are reachable instead of laying out 120px off-screen.
- Kingdom/Park profile court cards wrap; the "Open" link was up to 237px
  off-screen and is now a full-width 44px row action. Court_list.tpl gains its
  first media queries.

Overlays
- Recommendation rows stack their meta instead of destroying award/rank/date/
  age-badge under overflow:hidden (up to 196px was unreachable, with no
  horizontal scroll to recover it).
- Court Script header wraps, so Close no longer overlaps the Citation toggle by
  37px mid-ceremony; script entries stack at a readable scale.
- Modals get 16px gutters, dvh sizing, and a scroll lock on documentElement
  (body-level locking is a no-op here, since <html> is the scroll container).
- Autocomplete repositions/flips on scroll and resize, and is cleared on close
  so a stale result list can no longer bind a MundaneId the user never searched.

Touch / a11y / dark mode
- Reorder arrows were 30x15 because .cp-density-* out-specified both the mobile
  and pointer:coarse rules; driven through the density custom properties they
  now compute 36px in every density. Tracking icons reach 44px and carry a
  visible label, so their state no longer depends on hover-only data-tip.
- pointer:coarse extended to the controls that carry the workflow (fields,
  modal footers, rec-modal selects, giver pills, grant confirm) at a real 44px
  floor; inline font-size overrides that defeated the 16px iOS anti-zoom rule
  removed. Touch sizing is scoped to <=600px so it cannot overlap the fixed
  grid tracks on tablets.
- Status badges moved from PHP inline styles to modifier classes so
  html[data-theme="dark"] can reach them; light mode is byte-identical.
- Run mode gains a one-line sticky progress/presence bar, mirrored from the
  existing heartbeat updaters.

Bugs found along the way
- Court/list was dead at every viewport: index.php collapses route segments past
  the 3rd into a single string, so Controller_Court::list() received
  'kingdom/17' as $context with $id null and always rendered "Invalid location."
  Fixed in the controller (index.php's branch is global and has no other
  Court/list caller).
- Court_list.tpl read $courtList/$upcomingEvents but the controller supplies
  $CourtList/$UpcomingEvents, so the page rendered an empty state over real data
  and silently dropped the "Link to Event" select from the new-court modal.
- Plan-a-New-Court date field now uses the house Flatpickr altInput pattern with
  disableMobile:true, so the human-readable date shows on phones too; its Escape
  handler moved to the capture phase, where the guard actually runs before
  flatpickr closes the calendar.

Desktop verified unchanged at 1823px. Every fix is scoped to a media query,
pointer:coarse, or a mobile-only class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhD3rP2wzTgWPe4okEvYsu
# Conflicts:
#	orkui/controller/controller.KingdomAjax.php
#	orkui/model/model.Award.php
#	orkui/model/model.Player.php
@baltinerdist

Copy link
Copy Markdown
Contributor Author

Branch brought current with master

Synced up to master @ 6ab028a3 (merge commit 8cb99dc5). This branch was 263 commits behind; it's now 0 behind / 130 ahead.

Conflicts: 3 files, resolved. All three were upstream's domain-migration work landing on top of Court Planner additions.

1. orkui/model/model.Award.php — upstream's R-10 migration gutted this model into a thin pass-through (fetch_award_option_list()Award::GetAwardOptionListHtml(), plus an _award() helper). This branch had added a Court-Planner-only fetch_award_option_groups() for the ad-hoc Add Award/Title autocompletes, and otherwise only reformatted fetch_award_option_list (confirmed via git diff -w base..HEAD — no logic change on the branch side).

Resolved to upstream's thin version plus this branch's fetch_award_option_groups() and compareAwardsByName() kept verbatim. Note the deliberate choice: fetch_award_option_groups() was not rewritten on top of upstream's new Award::GetAwardOptionGroups(), because the two return different shapes — this branch returns ordered [{label, options:[…]}] with per-group IsTitle and Custom split into "Custom Award"/"Custom Title" groups, while upstream returns Groups/StandaloneOptions with raw award rows. Adapting one to the other mid-merge would have risked a silent behavior change in the Court picker.

2. orkui/model/model.Player.php — upstream PSR-12'd the file and added reset_waivers()/add_player_recommendation(); this branch added four Court/Recs methods in the same region. Kept upstream's structure and re-added all four (resolve_player_recommendation_cluster, snooze_recommendation, unsnooze_recommendation, set_recommendation_passed_to_local) in upstream's PSR-12 style. Verified no duplicate declarations, and that the corresponding lib methods on class.Player.php all survived the auto-merge.

3. orkui/controller/controller.KingdomAjax.php — upstream (R-18) replaced the inline geteventtemplates SQL with $this->Event->get_event_templates_for_kingdom(); this branch had inserted snoozerecommendation/unsnoozerecommendation handlers immediately above it. Kept upstream's model call and both branch handlers. Event::GetEventTemplatesForKingdom() was checked to return the identical shape (EventId/Name/ParkId/ParkName) the JS consumes, so no client-side breakage.

Method: git merge upstream/master, not a rebase — this branch has 130 commits behind prior merge commits, and a linear rebase would replay all of them against the new base.

Verification: no conflict markers; php -l clean on all 270 changed .php/.tpl; duplicate-method scan across 239 changed PHP files found none; class.Authorization.php untouched by both sides with no bypass. Feature spot-checks post-merge: the stage/finalize state machine in class.Court.php is intact ('staged' -> 'given' atomic claim + revert paths), and Recs Manager is still server-paginated ('Limit' => 500 + NextOffset, infinite scroll in Recommendations_manage.tpl) — independently re-confirmed on the pushed branch.


Worth a maintainer's eye:

  1. ⚠️ The snoozerecommendation / unsnoozerecommendation handlers have no controller-level auth guard, unlike the adjacent restorerecommendation block which returns status 5. That's how they shipped on this branch — presumably auth lives in the lib — and it was preserved as-is rather than hardened mid-merge. Worth confirming the lib really does gate them.
  2. Award classification logic is now duplicated between Model_Award::fetch_award_option_groups() and upstream's Award::GetAwardOptionGroups(). A consolidation follow-up, out of scope for a merge.
  3. Unrelated upstream change to be aware of: upstream's new GetAwardOptionListHtml() emits Custom options after the optgroups, where the pre-merge code emitted them between Ladder and the rest (upstream commit 6d06e4c0). Not introduced here, but it changes picker ordering.
  4. The two snooze handlers were re-indented from tabs to spaces and their one-line if (!valid_id(...)) {…} guards expanded, to match the now-PSR-12 file. No logic changed; no php-cs-fixer was run.

PR now shows MERGEABLE / CLEAN.

🤖 Generated with Claude Code

baltinerdist and others added 2 commits August 18, 2026 09:46
…main layer

The Court Planner work left five raw $DB->DataSet() sites in the controller
layer. Each is moved to a domain class in system/lib/ork3/ with identical SQL
and return semantics; no behavior change.

- CourtAjax::lookupPersona -> reuses the existing Player::GetPersona().
- CourtAjax::update_award_tracking_status -> reuses the existing
  Court::getCourtAwardCourtId() (already used by skip_award for the same
  lookup).
- ParkAjax 'checkrecommendation' -> new Player::GetPeerAwardRecommendations(),
  with a thin Model_Player::get_peer_award_recommendations() pass-through.
- Reports::courts park/kingdom name lookup -> new Court::getCourtReportScope(),
  called the same way as the adjacent getCourtReportList()/getCourtReportDetail().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Controllers reached into Ork3::$Lib->court directly in 69 places across
seven controllers, bypassing the model layer every other domain uses.

Adds orkui/model/model.Court.php: a thin pass-through fronting the Court
domain class, following the model.QualTest.php / model.KingdomProfile.php
pattern (snake_case wrappers over the camelCase lib API, delegating via a
private _court() accessor). No SQL and no business logic live in the model
-- every method forwards its arguments unchanged, defaults included.

All 69 call sites in controller.Court, controller.CourtAjax,
controller.Kingdom, controller.Park, controller.PlayerAjax,
controller.Recommendations and controller.Reports now go through
$this->Court, with load_model('Court') wired into the constructor for the
Court-centric controllers and into the specific actions elsewhere, matching
each controller's local convention.

Name resolution: Model_Court extends Model, so the base constructor derives
'Court' from the class name and startup.php has already require_once'd
DIR_ORK3 . class.Court.php, making the domain class available.

Pure call-convention change -- no behavior difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@baltinerdist

Copy link
Copy Markdown
Contributor Author

Follow-up: snooze guards verified, Court model layer added

Two items from the sync comment above, resolved.

1. Snooze handlers are authorized — no guard added, and none needed

I asked for evidence before adding anything, and the evidence says the lib enforces it. Model_Player::snooze_recommendation / unsnooze_recommendation (model.Player.php:235,240) are bare pass-throughs to class.Player.php, which does a two-stage check.

Player::SnoozeAwardRecommendation (class.Player.php:3954):

if (($mundane_id = Ork3::$Lib->authorization->IsAuthorized($request['Token'])) == 0) {
    return NoAuthorization();
}
...
// Auth: must be park admin for recipient's park
$recipientInfo = $this->player_info($awardRec->mundane_id);
if (!Ork3::$Lib->authorization->HasAuthority($mundane_id, AUTH_PARK, $recipientInfo['ParkId'], AUTH_EDIT)) {
    return NoAuthorization();
}

UnsnoozeAwardRecommendation (:3999) carries the identical pair.

The important detail: the scope is derived from the loaded recommendation's recipient ($awardRec->mundane_id → their ParkId), never from a request parameter — so it cannot be spoofed by passing someone else's kingdom_id. That makes it stricter than restorerecommendation's controller-level AUTH_KINGDOM/AUTH_CREATE check, not weaker. No IDOR. Independently re-confirmed on the pushed branch.

Same applies to the identical unguarded pair at controller.ParkAjax.php:427,439 — covered by the same lib enforcement.

2. Court model layer (fb1c2888)

69 call sites converted across 7 controllers — noticeably more than the ~44 estimated: CourtAjax 45, Court 11, Kingdom/Park/Recommendations/Reports 3 each, PlayerAjax 1. grep -rn 'Lib->court->' now returns nothing repo-wide (verified post-push).

New orkui/model/model.Court.php: 39 thin pass-through methods, zero SQL, zero logic (confirmed by grep). It follows model.QualTest.php — the closest analogue on this branch, also fronting a camelCase lib with snake_case wrappers and a private accessor. (The two models I'd suggested as templates, model.RBACService.php and model.OfficerPosition.php, don't exist on this branch — they're on #475.)

Name resolution is auto-wired, no registration needed: Model::__construct splits Model_Court on _ and takes Court; startup.php:52-56 has already require_once'd every DIR_ORK3/class.*.php, so Court is defined before any model instantiates.

Two decisions beyond the mechanical:

  • load_model('Court') placement — constructor for the Court-centric controllers (Court, CourtAjax, Recommendations, where nearly every action needs it and Recommendations uses it inside the shared resolveContext()); per-action for Kingdom, Park, PlayerAjax, Reports, matching each file's local convention.
  • Param/return types deliberately omitted on the model methods — the Court lib methods are untyped, and adding declarations would introduce coercion that could change behavior.

controller.Reports.php is tab-indented (60 tab lines) while the rest are spaces; edits there were surgical and tab-preserving rather than running php-cs-fixer over an otherwise-untouched file.

Verification: php -l clean on all 8 files; a static cross-check confirmed all 39 model methods forward to a real Court method with matching total and required arity, and all 69 call sites pass an argument count within range. A stub harness then instantiated Model_Court against a capturing Court stub and invoked all 39 public methods by reflection — 39 dispatched, 0 failures, each forwarding the full parameter list including defaults.

Not runtime-tested in the app — the worktree isn't what the local Docker instance serves. Verification is static plus that isolated dispatch harness.

🤖 Generated with Claude Code

baltinerdist and others added 8 commits August 18, 2026 21:06
…bile) into court-planner

Conflict resolutions:
- controller.KingdomAjax.php: keep master's model-layer refactor (KingdomProfile /
  AdminDashboard models, Authorization->has_authority) while preserving the branch's
  recommendation endpoints (passtolocal, resolvecluster, snooze/unsnooze,
  geteventtemplates) and the Anonymous/Granted rec fields. Restored the branch-only
  memcached flush after setconfig that the recorded resolution had dropped.
- model.Award.php: keep master's delegation of fetch_award_option_list() to
  Award::GetAwardOptionListHtml(), and re-point the branch's Court Planner
  fetch_award_option_groups() at Award::GetAwardOptionGroups() so award
  classification lives only in the lib layer instead of being duplicated
  (the copy's stated source, the old inline classifier, no longer exists).
- model.Player.php: master's PSR-12 rewrite plus the branch's four
  recommendation passthroughs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igrations

Two issues surfaced by bringing the branch current with master.

1. Password login was dead on any environment that had already applied
   2026-07-13-add-ork-session-table.sql. Master added user_agent/ip to
   ork_session by editing that migration in place, but the file leads with
   CREATE TABLE IF NOT EXISTS, so re-running it on a database built from the
   pre-metadata version adds nothing. CreateSession() then INSERTs both
   columns into a table that has neither; the insert fails, the verifying
   read-back finds no row, CreateSession returns '' and every login reports
   "Could not establish a session." Added an idempotent repair migration.
   Fresh installs were never affected — ork.sql and the current migration
   both declare the columns.

2. bin/run-ork-db-checks.sh failed on this branch: all nine Court Planner /
   recommendation migrations were unclassified in the ork-db manifest. All
   are pure DDL, so they are class S / render full. Migration coverage now
   passes (93 files classified).

The remaining drift-check failures (missing extracted/*.sql catalog
artifacts) reproduce on master and are not branch-introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…leak, id race)

Five findings from a hostile review of the Court Planner and Recommendations
Manager, each verified against the running app before and after the fix.

1. CRITICAL — Granting from the Recs Manager left the recommendation unresolved
   and made a duplicate award one click away. grantaward now reconciles court
   lines server-side (S1), but the client still ran its own post-grant courtStep
   against those same lines. Both branches hit the given-row guards this branch
   added — remove_award refuses a given row, set_award_status carries
   AND status != 'given' — so the promise chain threw BEFORE
   resolverecommendationcluster ever ran. The award landed in ork_awards, the rec
   was never soft-deleted, and the row stayed live with a working Grant button
   while the toast told the officer to retry. Player::AddAward has no duplicate
   guard, so a retry wrote a second permanent award — precisely what the
   grant-safety spec exists to prevent, on the routine triage path.

   Deleted courtStep. The officer's leave/remove choice now travels as a
   CourtAction parameter and reconcileGrantForRecommendation applies it as the
   line's terminal status: 'leave' -> given, 'remove' -> cancelled (soft-cancel,
   so the planned line's audit trace survives; the old client path hard-DELETEd).
   Cluster resolve is no longer gated behind court cleanup, and the
   granted-but-not-cleared path now retires the row instead of inviting a retry.

2. HIGH — Finalize published the internal officer note as the player's permanent
   public citation. commitStagedAward fell back PublicComment -> RecReason ->
   Notes; that third branch is not in the spec. Notes is the internal column
   ("hold until the drama settles"), and ork_awards.note renders on the
   recipient's profile to every visitor with no post-finalize undo. Two one-click
   paths reached it: an ad-hoc walk-on award with notes filled, and Plan-mode
   "Record grants", which stages every planned row without touching
   public_comment. Dropped the Notes branch.

3. HIGH — addAward()/addArtisan() named the row they had just inserted with
   "highest id for this court" instead of LAST_INSERT_ID(). That query is not
   connection-scoped, so under the concurrent editing this tool was built for one
   officer's response carried another officer's row id, and every follow-up
   action keyed off it mutated the wrong award. Both now use LAST_INSERT_ID(), as
   createCourt() already did.

4. MEDIUM — CSV export was open to spreadsheet formula injection. Reasons and
   personas are written by ordinary players; fputcsv() handles quoting, not
   formula triggers, so a reason starting with = + - @ executed on open in the
   officer's spreadsheet. Added csvSafe() over every free-text cell.

5. MEDIUM — CourtAjax ran two raw $DB queries (lookupPersona, and the court_id
   lookup in update_award_tracking_status). Moved to the lib layer as
   Court::getPersona() and the existing Court::getCourtAwardCourtId().

Verified live: leave/remove both reconcile correctly and write exactly one
ork_awards row; the cluster resolve that was previously unreachable now soft-
deletes the rec; add_award's returned id matches the inserted row; an
internal-notes-only award finalizes with an empty public citation; a
=HYPERLINK() reason exports neutralized. Test data was reverted from the shared
dev DB.

The stage->finalize core, object-level authorization on every court_award_id
endpoint, the complete-bypass rejection, and the row_version plumbing were
attacked directly and held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the adversarial-review fixes. The review raised this as a critical
finding and a refuter dismissed it on the grounds that the only wired sender of
Status=given was the Recs Manager's post-grant courtStep, which the spec
sanctioned. That reasoning was sound at the time — and the previous commit
deleted courtStep, which removed the justification along with the sender.

set_award_status writes the status column only; it never links award_id. With
'given' still in the client-settable whitelist, a POST of Status=given against
any planned/announced/staged row produced `status='given' AND award_id IS NULL`
— the one state the grant-safety spec forbids outright. Such a row is invisible
to finalize (getStagedAwards selects status='staged' only), so the recipient
never receives the ork_awards row, and setAwardStatus's own `AND status !=
'given'` guard means nothing can move it back. Terminal, silent, unrecoverable.

Reproduced against the running app before the fix: POSTing Status=given to a
planned row returned {"status":0,"award_status":"given"}, left award_id NULL,
and every attempt to restore it was refused as "already granted". After the fix
the same POST is rejected as an invalid status.

A line now reaches 'given' only through commitStagedAward() or
reconcileGrantForRecommendation(), both of which link the real awards id in the
same statement. The endpoint's one remaining caller (cpUnskipAward) sends
'planned' and is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dup line, order corruption)

The adversarial review raised 15 findings; 12 were adjudicated and 3 were never
handed to a refuter, so they were neither confirmed nor dismissed and did not
reach the report. All three are in the Court_detail client logic. Verified by
hand against HEAD and the running app; all three reproduce.

1. Wrong award added. cpAwardSearch() rebuilt the autocomplete on every
   keystroke but never cleared the hidden cp-adhoc-award-id. Selecting "Order of
   the Rose" and then editing the text without picking a new suggestion still
   posted the ROSE id: the visible text and the submitted award disagreed, and
   the wrong honor landed on the plan where it could be staged and finalized
   into the permanent record. Now cleared whenever the text stops matching the
   remembered selection — compared rather than cleared unconditionally because
   the same handler is wired to onfocus, where the selection must survive. This
   is the pattern cpAcSearch() (the player field) already used.

2. One recommendation on two court lines. cpSubmitRecs() left "Add Selected"
   enabled while its POSTs were in flight, and addAward() had no per-court
   dedup, so a double-click created two court_award rows for one rec. Per-line
   idempotency does not help: claimStagedForGrant guarantees each LINE commits
   once, so both lines finalize into two ork_awards rows. Fixed at both ends —
   the button locks for the batch, and a rec-backed add now returns the existing
   live line (AlreadyOnCourt) instead of inserting. The dedup deliberately
   ignores 'cancelled' rows so an officer who skips a rec and then re-adds it
   still gets a fresh line, and it never overwrites the existing line's
   notes/comment/rank — a stray double-submit must not clobber real edits.
   cpAppendAwardRow() also refuses to paint a second DOM row for a
   court_award_id already on screen, which likewise protects the delta-sync path.

3. Running order silently rewritten. The Printing List view physically reorders
   the DOM (scroll-tracked rows to the end, red before green) and hides the
   rest, while cpSaveOrder() serialises every .cp-award-row including the hidden
   ones. Any reorder performed while that view was active persisted the filtered
   arrangement as the court's real running order. Reordering is now refused with
   an explanation while the view is on (arrows, drag, and both sort buttons).

Verified live end to end, and the Recs Manager's full function set was re-tested
after these changes rather than only the parts that were edited: paging and every
filter/sort, snooze, unsnooze, pass-to-local and its reversal, dismiss, add to
court, double-add (one row, same id returned), re-add after skip (new line),
create court, grant on both court actions, and CSV export. Test data was reverted
from the shared dev DB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…all sites) into court-planner

Conflicts were all CSS, from master's DRY refactor landing on top of the Court
Planner's modal styles.

- orkui.css body rule: master normalized tabs to spaces but still carried the old
  combined `html, body { overflow-x: hidden }`, which makes BOTH elements vertical
  scroll containers and produces duplicate scrollbars. Kept this branch's split
  (overflow-x:hidden on <html> only, overflow-x:clip on <body>) in master's
  normalized style, and preserved master's added scrollbar-gutter.

- revised.css: master merged every modal overlay base, .open state, header and
  footer into single DRY rules, while this branch had added the Court Planner's
  #kn-addcourt-overlay / #pk-addcourt-overlay and the #kn-cp- / #pk-cp-new-court-
  modal blocks. Resolved as a union rather than by re-declaring: the court
  overlays are folded into master's merged selector lists, master's expanded
  header/footer selector lists are kept, the standalone .pk-badge-ladder that
  master DRY'd into the .kn-badge-ladder rule is not reintroduced, and the
  new-court-modal blocks ride along unchanged.

- revised.css dark-mode recs table: kept this branch's `table.pk-recs-table`
  scoping. Master still had the older `table.dataTable` selector, and narrowing
  that scope was the point of the original bugfix.

Verified: no branch-added rule lost (the only lines the sweep flags are the
duplicate overlay declarations deliberately folded into master's merged rules,
whose selectors remain); braces and comments balanced in both files; 19 changed
PHP files lint clean; migration coverage still passes; Court detail/list, Recs
Manager, both court reports, kingdom/park/player profiles and the directory all
render without errors; the served revised.css still carries the court modal rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-fix work

origin/feature/court-planner had three commits this local branch did not: a
merge of upstream/master, "Move Court Planner DB access out of controllers into
the domain layer", and "Route Court Planner calls through a Court model" (new
orkui/model/model.Court.php). They overlap heavily with the adversarial-review
fixes made here, so this is a merge, not a replacement — none of that work is
dropped.

Conflict resolutions, all resolved toward the refactor's call convention while
keeping the fixes' behavior:

- CourtAjax::lookupPersona and the update_award_tracking_status court_id lookup:
  took the incoming routing (Ork3::$Lib->player->GetPersona and
  $this->Court->get_court_award_court_id). Both are the fuller version of the
  review's finding #5, which had moved the same two queries into Court:: — so
  Court::getPersona is now unreferenced and is removed rather than left as dead
  code. Player::GetPersona / Model_Player::get_persona is what the other four
  controllers already use.

- PlayerAjax grantaward: took the incoming model routing
  ($this->Court->reconcile_grant_for_recommendation) and carried through this
  branch's CourtAction argument and returned line count, which the incoming
  version predates. Model_Court::reconcile_grant_for_recommendation gains the
  matching $court_action parameter so the officer's "leave on court" /
  "remove from court" choice still reaches the domain layer.

- model.Award::fetch_award_option_groups: kept this side. The merged method body
  delegates classification to Award::GetAwardOptionGroups(), so the incoming
  side's compareAwardsByName helper and its comment describing an inline
  classifier no longer apply.

Verified after the merge: every review fix is still present (CourtAction
threading, addAward rec-dedup, LAST_INSERT_ID on both insert sites, the dropped
internal-note citation fallback, getArtisans, 'given' removed from
set_award_status, csvSafe, courtStep gone, cpReorderBlocked, award-search
selectedName). Re-tested live through the new model layer: grant with
CourtAction=remove cancels the line and writes exactly one award row; with
=leave marks it given and links award_id; a repeat add returns the same
court_award_id with AlreadyOnCourt; set_award_status still rejects 'given';
court_state and update_award_tracking_status respond; Court detail/list, Recs
Manager and both court reports render clean. Test data reverted from the dev DB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l review

1. CRITICAL — one honor could reach the permanent ledger many times. The add-to-
   court dedup keyed on recommendations_id, but the honor is the cluster
   (recipient + award + rank). Several people recommending the same person for the
   same award is routine — the production mirror has clusters of fifteen — and the
   picker emitted one row per recommendation, so ticking them added one court line
   each. At finalize every line is its own idempotency key, so AddAward ran once
   per line. Now: the dedup probe matches the rec id OR the cluster key (the same
   match the reconcile already builds), getPendingRecommendations groups by cluster
   and carries a support count, and finalize tracks the clusters it has committed
   and cancels any later line for the same honor rather than leaving it to commit
   on the next run. Verified: adding two real sibling recs now yields one line.

2. CRITICAL — the cross-path reconcile rewrote courts it had no business touching.
   Its UPDATE matched on a global rec id or the cluster key with no court join: no
   court scope, no authority check, no court-status check. Finalize's "Leave As-Is
   and Close" legitimately leaves 'planned' lines on a completed court, so a
   Recs-Manager grant months later flipped a finalized court's line to 'given' and
   stamped it with the new award and giver — publishing an honor on the login-free
   Court Report for a ceremony where it was never announced. Nothing in that
   request path authorized against the rows being mutated. Now candidates are
   selected (excluding complete courts), filtered per row through canManage against
   that row's OWN court, and only then updated by explicit id; an actor id of 0
   reconciles nothing. Verified: a completed court's line is untouched, a
   kingdom-17 officer cannot touch a kingdom-18 court line, and the ordinary path
   still reconciles.

3. HIGH — anonymous recommenders were unmasked to the wrong people. Masking was
   computed from the requesting viewer and then written into a response cached for
   300s under a key with no viewer dimension: an admin warming the cache exposed
   every anonymous recommender to non-admins for the life of the entry, and a
   non-admin warming it blinded admins. Masking now happens per request in
   applyViewerFlags(), after the viewer flags are derived, so it also restores the
   ability of someone who filed an anonymous recommendation to edit their own
   reason — that was broken even uncached, because the masked null id could never
   match the viewer. Verified in both warm orders, and for the author.

4. MEDIUM — a withdrawn grant could still be committed. unstageAward reverted only
   the status, leaving the giver and public citation the officer had just
   withdrawn, and 'staged' was client-settable through set_award_status, which
   writes status alone and skips the giver/citation capture. Together those let
   finalize commit rejected values past the giver backstop. unstageAward now clears
   the giver and citation ('' — yapo drops nulls), and 'staged' has left the
   whitelist; staging happens only through grant_award/bulk_record_grants.

5. MEDIUM — Run mode never delivered the one-tap grant the mode exists for. The
   badge and row actions keyed purely off status, so a live ceremony showed the
   amber "Staged" badge and "nothing is recorded until finalize" wording that spec
   S2 says to hide. Run mode now renders a staged row as Given with an Undo, in
   the PHP render and both JS renderers. Display only — the row stays 'staged'
   underneath and Undo still un-stages, so the finalize guarantees are unchanged.

6. LOW — removed a QW#4 "keep the status select in sync" block whose target
   element does not exist anywhere in the file.

Verified live for each fix, plus a full page smoke test; the dev DB was restored
to its pre-test state. Not covered: no concurrency was exercised, and PHPUnit
still cannot run here (no composer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant