Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions docs/adr/0008-awol-accountable-day-model.md
Original file line number Diff line number Diff line change
@@ -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.