From 5c363784cc4d68ea3b80395d5eadb7e8aba39ce8 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:27:38 -0400 Subject: [PATCH] docs(adr): record AWOL accountable-day model + LOA history store (#157) Correct the AWOL glossary entry (7-day forum-activity rule, bot-derived, unrelated to milpac) and add the Regiment-time (UTC), Accountable-day, and one-thread-one-LOA / LOA-resets-clock domain facts surfaced while triaging #157. Add ADR 0008 capturing the load-bearing decisions: accountable-day counting, the LOA cache becoming a retained history store, days-AWOL display, and loud degraded-mode fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 25 ++++++- docs/adr/0008-awol-accountable-day-model.md | 76 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0008-awol-accountable-day-model.md diff --git a/CONTEXT.md b/CONTEXT.md index d1eaae1..41c7bff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,6 +17,9 @@ belong here, not inline in code comments. Canonicalized in `commands/afsm.go`. - **Position** — free-text string like `2/B/1-7`, `Reservist`, or a department name (`S1`). +- **Regiment time (UTC)** — UTC is 7Cav standard time. Wherever the bot has to + decide what calendar day something falls on (e.g. AWOL day-counting), a + "day" is a **UTC calendar date**. ## Member records @@ -39,7 +42,12 @@ belong here, not inline in code comments. - **LOA (Leave of Absence)** — a forum thread declaring a member away from duty for a date range. Parsed from a specific Xenforo BBCode template (yellow `[COLOR=rgb(213, 185, 0)]` labels around `Username`, `Start Date`, - `End Date`). Template drift silently breaks parsing. + `End Date`). Template drift silently breaks parsing. One thread is exactly + one LOA — a second LOA always means a new thread, so `ThreadID` uniquely + identifies an LOA. **Filing an LOA is itself a forum post**, so it resets the + trooper's last-post clock; this is why a long unexcused gap immediately + followed by an LOA is rare in practice (the accountable-day model still + handles it correctly if it occurs). - **LOA node** — a Xenforo forum section that hosts LOA threads. Production scans five (`180,400,540,178,369`); the code default is `180`. - **LOA cache** — `utils.GlobalLOACache`, the in-process cache populated by a @@ -56,9 +64,20 @@ belong here, not inline in code comments. received it yet, scoped to one of the departments in the AFSM enum above. - **S6-IT full status** — promotion from probationary to full member of the S6 IT team. `/s6-it-check` enumerates eligible members. -- **AWOL** — a trooper flagged absent without leave in their milpac. `/awol` - lists current AWOLs for a position, with an "On LOA" indicator sourced +- **AWOL** — a trooper who has not posted on the 7Cav forums within the last + **7 days**. Active membership requires at least one forum post per week + (typically a roll-call post, but *any* post on the 7Cav forums qualifies a + trooper as not AWOL). AWOL is a bot-derived signal computed from forum + activity — it has **nothing to do with the milpac record**. `/awol` lists + current AWOL candidates for a position, with an "On LOA" indicator sourced from the LOA cache. +- **Accountable day** — a UTC calendar date that counts toward a trooper's + AWOL total. The candidate dates are those strictly after the trooper's last + forum post up to and including today (`(lastPostDate, today]`); a date is + **accountable** unless it is covered by an LOA. A trooper is an AWOL + candidate when their accountable days exceed 7. Because coverage is a set of + dates, overlapping, adjacent, and future LOAs need no special handling — the + union of covered dates falls out naturally. - **Accuracy disclaimer** — because milpac records are user-entered free text, parsing can drift. `/afsm` always renders the disclaimer, regardless of whether the eligibles list is empty — see ADR 0002. diff --git a/docs/adr/0008-awol-accountable-day-model.md b/docs/adr/0008-awol-accountable-day-model.md new file mode 100644 index 0000000..02f2c64 --- /dev/null +++ b/docs/adr/0008-awol-accountable-day-model.md @@ -0,0 +1,76 @@ +# ADR 0008: AWOL accountable-day model + LOA history store + +## Status + +Accepted. Implemented across #158 (LOA history store) and #159 (`/awol` +accountable-day count). Supersedes the original `/awol` behavior where the +**On LOA** indicator was purely informative and never affected the flag. + +## Decision + +`/awol` flags on **accountable days**, not raw time since last forum post. + +- **AWOL** = no qualifying forum post in the last 7 days. Bot-derived; unrelated + to the milpac record. (CONTEXT.md corrected accordingly.) +- A **day** is a **UTC calendar date** — UTC is 7Cav standard time. +- **Accountable day:** candidate dates are `(lastPostDate, today]` (post day + excluded, today included). An **LOA** covers the inclusive range + `[StartDate, EndDate]`. Coverage is a **set of dates** — overlapping, + adjacent, multiple, and future LOAs need no special handling; the union of + covered dates does the work. A date is accountable unless covered. +- Flag when accountable dates **> 7** (no grace buffer — whole-date counting + already absorbs the intra-day slop the old magic `8` was compensating for). +- The displayed figure is **days AWOL = accountable − 7** (the overage past the + requirement), so the number on screen means exactly what the word says. + +The **LOA cache becomes an LOA history store**: + +- Multiple windows per username, deduped by **`ThreadID`** (one thread is one + LOA; a second LOA is always a new thread). +- **Retention** replaces prune-on-expiry: a window is kept until its `EndDate` + is older than the cold-start backfill horizon, sharing one constant with that + backfill so warm and cold-started caches hold the identical window. +- New `GetEntries(username)`; `GetEntry`/`IsOnLOA`/`IsHealthy` unchanged so the + store's two consumers (`/awol`, `/loa`) keep working. + +**Degraded mode:** when the cache is unhealthy, `/awol` computes raw inactivity +with no LOA subtraction, still flags `> 7`, and reuses the #96 unknown/warning +treatment — worded so staff know accountable-day adjustment was *skipped*. It +never silently treats everyone as non-LOA. + +## Why + +The raw last-post date produces false-positive AWOLs: a trooper back from a long +approved LOA reads as "45 days AWOL" the instant the leave expires, and staff +can action someone who was excused per SOP. Subtracting LOA-covered dates fixes +this at the source. + +The history store is forced by the math: the previous cache kept **one** window +per user and pruned it the day it ended, so it could neither represent multiple +LOAs nor recall a recently-ended one — both of which the accountable count +needs. Modeling coverage as a **set of dates** is what makes overlap/adjacency/ +future-LOA handling fall out for free instead of as bespoke merge logic. + +Showing the **overage** (days AWOL) rather than the total unexcused count keeps +the label honest: "16 accountable days" invited the same "but it says 16!" +confusion the feature set out to kill, whereas "9d AWOL" is unambiguous. Layout +and severity glyphs (🔴 `>14` · 🟠 `>7` · 🟡 `>0` · ⚪ active LOA) were validated +against live Discord rendering before being locked. + +Degrading loudly rather than suppressing the report trades a rare, *visible* +false positive for never blinding staff during a forum-DB blip — the right call +for an accountability tool where humans are the safety net. + +## How to apply + +- An LOA excuses **only its own dates** — it never retroactively covers a gap + that preceded it. A trooper currently on LOA who accrued >7 unexcused dates + before it is still flagged ("on LOA, still AWOL", ⚪ glyph). This is + intentionally stricter than "currently on LOA → never AWOL"; it is rare in + practice because filing an LOA is itself a forum post that resets the clock. +- When touching the LOA store, preserve the `GetEntry`/`IsOnLOA`/`IsHealthy` + contracts — `/loa` depends on them too, not just `/awol`'s LOA tag. +- Keep the accountable-day math a pure function (dates + windows → days AWOL) + so every edge case is table-testable without Discord, the DB, or the clock. +- A malformed LOA range (end before start) must cover no dates and be + DEBUG-logged — never crash or skew the count.