diff --git a/CONTEXT.md b/CONTEXT.md index cfe5e69..08b874d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,7 +29,7 @@ A synthetic, ordinary All-Day Event that Hawk Bot generates automatically for ea _Avoid_: "sub-event", "day instance" (suggests calendar recurrence, which this isn't) **Event Check-in Post**: -The single Slack message posted before an Event that both reminds the team of it and serves as the surface people react to for attendance. Includes the Event's title, date/time, location, description, and a short reminder of what each reaction means. Posted to the announcements channel with an `@channel` mention. The bot pre-populates it with πŸ‘, a Clock Reaction, and ❌ so a team member can just click an existing reaction rather than pick their own emoji from scratch; picking something else entirely still works and is read the same way. The bot deletes it at the Reaction Cutoff, which finalizes attendance for that Event. +The single Slack message posted before an Event that both reminds the team of it and serves as the surface people react to for attendance. Includes the Event's title, date/time, location, description, and a short reminder of what each reaction means. Posted to the announcements channel with an `@channel` mention. The bot pre-populates it with πŸ‘, a Clock Reaction, and ❌ so a team member can just click an existing reaction rather than pick their own emoji from scratch; picking something else entirely still works and is read the same way. At the Reaction Cutoff, which finalizes attendance for that Event, the bot locks the post rather than deleting it β€” see Reaction Cutoff and ADR-0013. _Avoid_: pre-event reminder post, check-in post (as if separate from the reminder) **Check-in Post Timing**: @@ -42,7 +42,7 @@ What happens when the Team Meeting Calendar changes after an Event's Check-in Po - **Removed**: reply in the thread the same way, announcing the cancellation, _and_ edit the original post's text in place β€” the removal notice up top, the meeting's details kept below it (struck through, not deleted, minus the now-irrelevant reaction legend) β€” so the cancellation is visible, with context, to someone who never opens the thread. No Reaction Cutoff runs and no hours are credited for a removed Event. **Reaction Cutoff**: -The moment the bot deletes an Event's Check-in Post, freezing whatever reaction state existed at that instant as final for that Event. Immediately before deleting, the bot re-fetches the post's live reactions from Slack and reconciles them against its own running tally, so the frozen state matches what people actually see on the message, not just what the bot's event stream caught. Default: midnight of the event day β€” unless the Event's scheduled end time is at or after midnight, in which case the cutoff moves to 10am the next morning. +The moment the bot locks an Event's Check-in Post, freezing whatever reaction state existed at that instant as final for that Event. Immediately before locking, the bot re-fetches the post's live reactions from Slack and reconciles them against its own running tally, so the frozen state matches what people actually see on the message, not just what the bot's event stream caught. Locking edits the post in place β€” never deletes it (see ADR-0013): the header becomes "βœ… _Title_ β€” attendance closed," the `` mention and reaction legend are dropped since neither is actionable anymore, and the meeting details plus every reaction already on the post β€” the bot's seeded ones and anyone's own β€” are left exactly as they were. Default: midnight of the event day β€” unless the Event's scheduled end time is at or after midnight, in which case the cutoff moves to 10am the next morning. **Attending / Not Attending / No Response**: The three attendance outcomes for a team member on an Event, fixed at the Reaction Cutoff. Attending means a πŸ‘ (any skin tone) reaction is present β€” full scheduled-hours credit, regardless of what else is also on the post. Not Attending means at least one reaction is present but no πŸ‘. No Response means no reaction at all. @@ -64,10 +64,10 @@ The reporting window "attendance %" and hours-credited figures default to. Fixed v1 has exactly two reporting surfaces; everything else the original spec sketched (per-event breakdowns, no-response lists, a leaderboard) is explicitly deferred to a later feature. A team member's own Season attendance %/hours is self-serve, open to anyone β€” it's their own data. A full CSV export of the whole team's Season attendance is admin-only and delivered by DM to whoever ran it, never posted into a channel β€” a bulk export of everyone's data is a different exposure than a personal lookup. **Reaction Cutoff Verification**: -Immediately after the Reaction Cutoff's live resync and immediately before deleting the Check-in Post, the bot checks two things: the resync's Slack API calls completed without error, and every person with a qualifying reaction on the message has a matching attendance record. If either check fails, the bot does **not** delete the post or finalize the Event β€” it records the failure, DMs every HawkBot Admin, and leaves the Event alone. Nothing retries automatically; an admin has to explicitly re-run the cutoff for that Event once whatever went wrong is understood. +Immediately after the Reaction Cutoff's live resync and immediately before locking the Check-in Post, the bot checks two things: the resync's Slack API calls completed without error, and every person with a qualifying reaction on the message has a matching attendance record. If either check fails, the bot does **not** lock the post or finalize the Event β€” it records the failure, DMs every HawkBot Admin, and leaves the Event alone. Nothing retries automatically; an admin has to explicitly re-run the cutoff for that Event once whatever went wrong is understood. **Event Attendance Report**: -Posted to a private `#hawkbot-attendance-report`-style channel (exact channel is an admin-configurable setting) immediately after an Event successfully clears its Reaction Cutoff and its Check-in Post is deleted β€” one report per Event, never batched. Kept short in the channel itself: +Posted to a private `#hawkbot-attendance-report`-style channel (exact channel is an admin-configurable setting) immediately after an Event successfully clears its Reaction Cutoff and its Check-in Post is locked β€” one report per Event, never batched. Kept short in the channel itself: - **Top-level message**: the Event's name, the Attending/Not Attending/No Response counts, and the hours the Event was worth per attendee (e.g. "12 attended (2 hrs each) Β· 3 didn't attend Β· 2 no response") β€” not a summed grand total, since every Attending person on one Event is credited the same hours. Past tense throughout, unlike the Attending/Not Attending/No Response terms elsewhere β€” this posts after the Reaction Cutoff, so it's reporting what happened, not describing a still-open response window. - **Threaded reply**: a monospace table, one row per person β€” Name, derived Status ("Attended"/"Didn't Attend"/"No Response", same past-tense reasoning), the raw reaction(s) they left, and their Attendance Note if any. Showing derived Status alongside the raw reactions means a reader doesn't need to know the πŸ‘-wins-regardless precedence rule by heart. @@ -78,7 +78,7 @@ No link out to another surface β€” the detail lives entirely inside the thread r Replaces "Slack workspace Owner/Admin" as the authorization model for every admin-gated capability (`/hawkbot config`, `/hawkbot event create`, the season CSV export, and the Reaction Cutoff Verification failure DM) β€” full replacement, not an additional either/or check. Defined by membership in a dedicated Slack User Group β€” not Slack's built-in Admins group β€” so a team can grant bot-admin duties (mentors, team leads) without handing out full Slack workspace administration. An admin configures it by handle (e.g. `hawkbot-admins`); the bot resolves that to the group's Slack-internal id once, at set-time, and checks membership against the id from then on. Slack workspace Owners always retain HawkBot Admin rights regardless of the group's state, as a bootstrap/lockout safety net β€” otherwise an empty or unconfigured group would mean nobody could run the command needed to fix it. Group membership is cached (same 5-minute-TTL shape as the old per-user admin cache), rather than looked up per check. **Weekly Summary Post**: -A single Slack message posted to the announcements channel at an admin-configurable day/time (default Sunday, noon), listing every Event scheduled for the 7 days starting the day after that post β€” a rolling window, not aligned to any fixed Monday–Sunday calendar week (see ADR-0011) β€” a full planning digest, including Hourly, All-Day, and Multi-Day Events alike, regardless of whether an Event already has (or will soon have) its own Event Check-in Post. Only one Weekly Summary Post exists at a time: the previous one is deleted before the new one is posted. A Multi-Day Event appears here exactly once, as its own date-range line β€” its Multi-Day Child Events are never listed individually, so the same competition is never shown more than once. +A single Slack message posted to the announcements channel at an admin-configurable day/time (default Sunday, noon), listing every Event scheduled for the 7 days starting the day after that post β€” a rolling window, not aligned to any fixed Monday–Sunday calendar week (see ADR-0011) β€” a full planning digest, including Hourly, All-Day, and Multi-Day Events alike, regardless of whether an Event already has (or will soon have) its own Event Check-in Post. A new Weekly Summary Post goes out every cycle without deleting the previous one β€” see ADR-0014; the channel accumulates one post per week, each a snapshot of what was upcoming as of that post's date, rather than there ever being exactly one "current" post. A Multi-Day Event appears here exactly once, as its own date-range line β€” its Multi-Day Child Events are never listed individually, so the same competition is never shown more than once. _Avoid_: "this week" or "calendar week" implying Monday–Sunday alignment β€” "This Week" is the Slack-facing label, but the underlying window floats with whatever day the post lands on (see ADR-0011). **Weekly Summary Change Reflection**: @@ -91,7 +91,7 @@ How the Weekly Summary Post stays accurate through the week it covers. Unlike Ca This keeps reflecting an Event's changes for the rest of the week even after that Event gets its own Event Check-in Post β€” Calendar Change Handling's threaded, broadcast reply is an _additional_, more active notification for people already tracking that specific Event, not a replacement. A summary that went stale for any Event with a Check-in Post would defeat the point of it being a reliable weekly reference. See ADR-0005 for why this is in-place editing rather than the threaded-reply design originally sketched. **Informational Reply**: -The Informational Calendar's digest, if enabled: a threaded reply under that post's Weekly Summary Post, covering the same rolling window, rather than a separate top-level post or broadcast. Posted the first time an Informational Event shows up in a given window β€” not posted at all for a window with none, so an enabled-but-empty window stays silent rather than adding noise. Follows Weekly Summary Change Reflection's own rules for the rest of the window (edited/removed/new), and is deleted alongside the parent Weekly Summary Post at the next rollover. See ADR-0008 for why this threads when the Weekly Summary Post itself deliberately doesn't (ADR-0005). +The Informational Calendar's digest, if enabled: a threaded reply under that post's Weekly Summary Post, covering the same rolling window, rather than a separate top-level post or broadcast. Posted the first time an Informational Event shows up in a given window β€” not posted at all for a window with none, so an enabled-but-empty window stays silent rather than adding noise. Follows Weekly Summary Change Reflection's own rules for the rest of the window (edited/removed/new), and, like the parent Weekly Summary Post, is never deleted β€” see ADR-0014. See ADR-0008 for why this threads when the Weekly Summary Post itself deliberately doesn't (ADR-0005). **Mentor/Teacher Weekly Summary**: -The Mentor/Teacher Calendar's digest, if enabled: its own post, on its own admin-configurable day/time, to an admin-only channel (not the team announcements channel) β€” separate audience, separate schedule from the Weekly Summary Post. Covers 14 days starting the day after that post, rather than 7, for more lead time on travel and unavailability conflicts β€” the same rolling-window model as the Weekly Summary Post, just twice as wide (see ADR-0011). Deleted and reposted fresh each cycle like the Weekly Summary Post, but with no Weekly Summary Change Reflection β€” see ADR-0007. Safe for an adult-only channel despite the "no user scopes, shared with minors" rule in `CLAUDE.md`: a Mentor/Teacher Event only ever reveals that someone is unavailable, never why or where. +The Mentor/Teacher Calendar's digest, if enabled: its own post, on its own admin-configurable day/time, to an admin-only channel (not the team announcements channel) β€” separate audience, separate schedule from the Weekly Summary Post. Covers 14 days starting the day after that post, rather than 7, for more lead time on travel and unavailability conflicts β€” the same rolling-window model as the Weekly Summary Post, just twice as wide (see ADR-0011). Reposted fresh each cycle without deleting the previous one, same as the Weekly Summary Post (see ADR-0014), but with no Weekly Summary Change Reflection β€” see ADR-0007. Safe for an adult-only channel despite the "no user scopes, shared with minors" rule in `CLAUDE.md`: a Mentor/Teacher Event only ever reveals that someone is unavailable, never why or where. diff --git a/docs/adr/0013-checkin-post-is-locked-not-deleted-at-reaction-cutoff.md b/docs/adr/0013-checkin-post-is-locked-not-deleted-at-reaction-cutoff.md new file mode 100644 index 0000000..67372c6 --- /dev/null +++ b/docs/adr/0013-checkin-post-is-locked-not-deleted-at-reaction-cutoff.md @@ -0,0 +1,9 @@ +# The Event Check-in Post is locked in place at Reaction Cutoff, not deleted + +The Reaction Cutoff used to delete the Check-in Post outright once attendance was finalized. That left a gap: any threaded replies on the post β€” Attendance Notes, Calendar Change Handling's own broadcast replies β€” got orphaned by Slack the moment the parent was gone, since a bot can only ever delete a message it posted itself. There's no scope or admin grant that changes that; deleting a human's reply would require a user-level token belonging to an actual Workspace Admin, which is exactly what `CLAUDE.md`'s "no user scopes, ever" rule forecloses. + +Investigating the fix surfaced a second fact that made the whole deletion moot: every Attendance Note is already written to SQLite the instant it's posted (`setAttendanceNote`, driven by the `message` event, not read back from Slack at cutoff time). Nothing about the Check-in Post's continued existence in Slack was ever load-bearing for attendance data β€” the message itself was purely a UI surface, kept alive only so people had somewhere to react and reply. + +So the Reaction Cutoff now edits the post in place instead of deleting it, the same `chat.update` mechanism Calendar Change Handling already uses for an edited or removed Event: the header becomes "βœ… _Title_ β€” attendance closed," the `` mention and the reaction legend are dropped since neither means anything once responses are closed, and the meeting details plus every reaction already on the post are left untouched β€” including reactions people added themselves, which the bot has no ability to remove and no reason to want to. This also means the thread β€” Attendance Notes and all β€” simply stays attached to a visible, permanent message, rather than becoming orphaned content nobody can find. + +This is the same edit-in-place shape ADR-0005 chose for the Weekly Summary Post, arrived at independently and for a different reason (there: avoiding a second competing threaded-reply mechanism; here: Slack's own permission model leaves no other option that doesn't lose data or leave orphans). diff --git a/docs/adr/0014-weekly-and-mentor-summaries-are-never-deleted.md b/docs/adr/0014-weekly-and-mentor-summaries-are-never-deleted.md new file mode 100644 index 0000000..ef1e2bb --- /dev/null +++ b/docs/adr/0014-weekly-and-mentor-summaries-are-never-deleted.md @@ -0,0 +1,9 @@ +# The Weekly Summary Post and Mentor/Teacher Weekly Summary are never deleted + +Both used to delete the previous cycle's post (and, for the Weekly Summary Post, its Informational Reply) immediately before posting the new one β€” the mechanism behind "only one Weekly Summary Post exists at a time." Like the Event Check-in Post (ADR-0013), that left orphaned threaded content behind: nothing in this app has ever been able to enumerate and delete a thread's replies, and a bot token fundamentally cannot delete a reply someone else posted, so any human reply on a summary or its Informational Reply was already unrecoverable the moment the parent vanished. + +Unlike the Check-in Post, nothing about a Weekly Summary Post's or Mentor/Teacher Weekly Summary's own content needed to survive being read back later β€” there was no equivalent of Attendance Notes depending on it. The fix here is simpler: stop deleting it, period. Each cycle's post β€” and Informational Reply, if the Informational Calendar produced one β€” now just stays in the channel indefinitely, and a fresh post goes out alongside it next cycle rather than replacing it. Old posts aren't edited to mark them stale; they're left exactly as they were when current, a dated snapshot of what was upcoming as of that post. + +`getMostRecentWeeklySummary()` / `getMostRecentMentorSummary()` keep exactly the meaning they already had β€” "the one this cycle is live-editing or appending to" (Weekly Summary Change Reflection, ADR-0005) β€” since that was always "most recently inserted," never dependent on older rows being deleted. Removing the delete calls doesn't touch that. + +The tradeoff accepted here: the announcements channel (and the mentor-only channel) will accumulate one post per cycle forever, rather than staying down to a single current one. That's judged worth it β€” an unbounded but slow-growing history of past weeks' plans is a mild channel-tidiness cost, not a functional one, and it's the only option that doesn't either lose thread content or require permissions this app deliberately doesn't have.