Skip to content

Guilds, Calendar and Inbox — binding design decisions

Date: 2026-08-05 Epics: WING-335 (Guilds), WING-357 (Calendar & Application Tracking), WING-364 (Inbox Response Detection) Status: decisions binding; unverified facts listed at the end are NOT decisions

Three council sittings produced the decisions below. This document exists so the reasoning survives outside Jira descriptions and council transcripts — a later reader should be able to tell why a constraint exists before deciding to remove it.

Council seats: Cedric (Codex), Jenny (Gemini), Gronktayvius (Grok), Clahadore (Claude).


1. External data strategy — no third-party event APIs

Verified August 2026:

Source State Usable for discovery
Meetup REST retired; GraphQL only; OAuth consumer creation needs paid Pro, approval discretionary and revocable No
Eventbrite Public /v3/events/search/ removed Feb 2020; only by-ID / by-venue / by-org remain No
Luma Public API requires Luma Plus and keys are scoped to a calendar the user owns — an organiser API No
Reddit Free tier 100 QPM but non-commercial only, pre-approval under the Nov-2025 Responsible Builder Policy; commercial ≈ $0.24/1k, ~$12k/mo enterprise No

WorkWingman is commercial, so Reddit's free tier is prohibited to us.

Therefore the base layer is: a curated catalog (content, via wingcms), unauthenticated ICS + RSS adapters behind an IGuildFeedSource seam, confs.tech's open conference JSON, and curated deep links for Slack/Discord/subreddits with zero API calls.

Meetup — cut holds, original justification RETIRED

The first sitting cut the Meetup adapter on the grounds that "Meetup groups publish ICS, so the free path already covers them." That reasoning was wrong. Meetup's /events/ical links behave as one-time downloads and 404 without a logged-in browser session.

The cut still holds, on a corrected basis: no reliable unauthenticated feed and no non-organiser API path compatible with BYO credentials. Meetup is a deep link only, and coverage there is explicitly degraded — surface a visible requiresLogin / unsupportedSource state rather than a silently empty list.

Consequence: virtual coverage cannot depend on Meetup. It comes from sources that publish real feeds — state CPA and bar CE calendars, professional-body chapters, nursing associations, unions, university career centres, libraries, workforce boards, Toastmasters, SCORE. Most are national-virtual, which is the population virtual-first exists to serve.

The Luma cut holds on its original and stronger reason: organiser-scoped keys structurally cannot answer "what's near me."

Non-tech coverage is a taxonomy problem, not a source problem

confs.tech (tech-conferences/conference-data, MIT, verified active) classifies by technology. It cannot classify a nurse, a paralegal, or a millwright. Sources for non-tech professions exist in quantity; what does not exist is a confs.tech-equivalent aggregator.

Decision: adopt SOC / O*NET occupational codes as the catalog's classification spine (WING-400). confs.tech becomes one feed hanging off the tech branches, not the schema.

Decision: curate feeds, not events — a hand-entered event is dead in six weeks; a verified organisation with a feed keeps producing. Target ≥60% feed-backed.


2. Protected characteristics

Andrew's decision (2026-08-05)

The council recommended deferring affinity personalisation out of v1. Andrew overrode it: affinity ships in v1, with encryption, resume-export block and consent flow built up front as hard prerequisites rather than follow-ons.

Why "additive-only" alone was not sufficient

The original design proposed that affinity signals may only add guilds, never filter or rank down. That rule is necessary and is retained — but all four seats found it insufficient, for reasons worth preserving:

  • Adding a guild because of someone's race or orientation is itself special-category processing under GDPR Art. 9. The ICO treats intentional inference as processing even when the inference is uncertain. Additive-only addresses the filtering harm; it does nothing about the processing question.
  • The resume export path was an outing vector built into the design. GuildMembership emitting an Affiliations section meant an affinity-matched guild could disclose sexuality, religion or race to an employer — the exact harm the feature exists to prevent.
  • LocalJsonStore is plaintext, exposing special-category data to anyone with machine access or commodity malware.
  • A guild's name discloses the trait regardless of ranking. No ranking rule touches "National Society of Black Engineers" appearing in a document.

The controls that ship

  • Separate opt-in Affinity store, distinct from IntakeProfile.Demographics at the type level. Demographics exists for EEO form autofill; reusing it would be a purpose-limitation violation.
  • Encrypted at rest (DPAPI / Keychain), excluded from backup packs, telemetry, logs, crash reports and OS indexing; one-click wipe.
  • impliesProtectedTrait catalog flag (WING-397) driving: consent-gated surfacing per dimension, demotion where membership × geography narrows to near-identifiable, always-strip on export (not merely default-excluded), and never inferring affinity from joined guilds — membership is not consent to be profiled.
  • Additive-only invariant, enforced by a CI gate (WING-398): golden profiles differing only in affinity must produce byte-identical non-affinity results. The gate also detects stereotyped over-concentration — a Discover tab returning nothing but affinity organisations has failed the user as surely as one that excluded them — and covers intersections.
  • DPIA (WING-395) blocking the affinity layer only, not the whole epic.
  • Every recommendation shows its reason, citing the catalog tag the user opted into, never the demographic trait.
  • Framing is always "communities you might belong to", never "communities for people like you."

Shared devices — Andrew's call, recorded

DPAPI protects against another OS account, not against a sibling on the same profile — the actual family-PC case, and the largest remaining hole per all four seats.

Council required an app-level PIN/biometric, with affinity off if declined. Andrew decided warn-and-proceed instead: affinity available with a warning, lock optional (WING-396). The concern raised and overruled: consent does not transfer controller obligations under Art. 32, and on a shared machine the person harmed is often not the person who clicked.

Retained regardless, because they cost nothing: affinity hidden by default with explicit reveal, nothing in notifications / window titles / recent items / jump lists, fast panic-hide.


3. Virtual events are first-class

Andrew's decision, on morale grounds: a job seeker who is isolated, in a small market, disabled, caregiving, or unable to travel still needs community, and virtual events are how they get it.

Judged defensible on merit, not sentiment — weak ties drive referrals, and search persistence determines outcomes. It becomes scope creep at exactly one boundary: require a career or professional-development nexus on every catalog entry. Events and deep links are on-mission; chat, feeds and social graph are not.

The rule that matters most

Distance ranks. It never filters.

eligible = (attendanceMode ∈ {inPerson, hybrid} AND within radius)
        OR (attendanceMode = online)

An AND between "has a city" and "is nearby" is the bug that silently deletes every online event for exactly the users who need them most. Online is included by default when a user sets a city; exclusion is an explicit opt-out. Missing lat/lon is never treated as "far away."

This is structurally the same rule as additive-only, applied to geography instead of identity.

Privacy note

Virtual does not relax the polling calculus — it is slightly worse. A local-chapter feed carries geographic plausible deniability; an online-only affinity group's feed carries none, so the poll discloses affiliation more cleanly.


4. Calendar model — the defect that was caught before code

The original filing said "store a canonical UTC instant plus the IANA origin timezone." Cedric caught that this is wrong for two of three cases, and no other seat saw it, including the seat that wrote it.

The model carries three time shapes:

Shape Storage Why
instant UTC instant A specific one-off interview
zonedRecurring wall time + TZID + RRULE, UTC derived at expansion Anchoring a recurrence to a UTC instant drifts every occurrence by an hour after a DST transition — a weekly 10:00 screen becomes 09:00 in November
dateOnly date, no zone Deadlines are VALUE=DATE; coercing to a midnight instant moves them a day for users west of UTC

Also required: externalUid + SEQUENCE/ETag + source-account identity for idempotent re-import and revocation cleanup; tombstones; cancelled distinct from deleted; explicit re-import conflict policy.

Ical.Net v5 (v5.2.2, May 2026, active) — pinned, wrapped behind a serializer port so no library type crosses into the domain, with hostile-ICS limits (expansion caps, size caps, fuzzing).

Happenings becomes a view over the calendar, not a parallel timeline — correct as a read-model projection over a shared event envelope, wrong if Guilds and Calendar share mutable entities.


5. Reminders — four surfaces, and the cheapest one wins

Surface Mechanism Server
Everywhere ICS export → the user's own calendar none
Desktop On-open surfacing none
Phone (paired) OS local notifications none
Cloud / unpaired Push — wake-on-change only yes

ICS export is the biggest lever. It is the only mechanism that reaches devices we will never ship to — a work laptop, a shared calendar, a watch, a car display. One story, no infrastructure.

Push shrank from "largest hidden cost in the epic" to lowest priority, blocking nothing. Local-first was never violated: it is a desktop-edition property, and the cloud and phone editions are server-backed by definition.

Platform trap to design around: iOS caps pending local notifications per app (64 historically — confirm current). A user with many tracked applications silently loses the overflow, and a three-event test will never surface it.


6. Inbox response detection

Outcomes are user-reported. WorkWingman never reads an inbox to auto-detect responses.

Variant B (mail relay) — killed

Unanimous. Operating a mailbox that receives employer correspondence makes WorkWingman a data controller for third parties — recruiter identity, signature blocks, sometimes salary or references — none of whom are users and none of whom consented.

  • Parse-and-discard is a retention control, not a collection control. Receipt is processing. It shrinks breach blast radius and changes nothing legally.
  • A WorkWingman-branded relay the user trusts is a phishing amplifier for forged "you got an offer" mail, aimed at people at their most vulnerable. This argument alone settles it.
  • Forwarding breaks SPF by design, so inbound authentication can never prove a message is genuine.
  • It ends the only claim a competitor cannot copy by shipping faster: your job search never leaves your machine.

Reopening requires a signed amendment to the local-only product principle, privacy counsel, and a DPIA. A principle should not be repealed inside a story.

Every objection to the generated filter was an objection to forwarding, not to filtering:

  • "Over-broad by construction" — only matters because data leaves the mailbox. As a search, over-broad costs the user three extra rows in a list they are already looking at.
  • "Preview cannot prove correctness" — irrelevant when nothing is exfiltrated.
  • "Staleness means rewriting the user's mail config" — only if it is a stored rule. A generated search URL is stateless; build it fresh each time.

The surviving objection is false negatives, which the tiering below handles.

What ships (WING-368), cheapest first

  1. Search-URL deep link. Gmail, Outlook and Yahoo all take search as a URL. The provider's own engine filters; we read nothing. Provider-agnostic, no permissions, ~1 day.
  2. Thread anchoring. The application confirmation email identifies the exact thread; every later message in it is about that job by definition. No classification, and a thread identity is stable across provider UI churn in a way DOM selectors are not.
  3. Trust factor. Sender matches the ATS we submitted through; timestamp after the application is a hard gate, not a weight — anything earlier cannot be a callback, which removes the entire job-alert false-positive class; response-window decay; company name in subject.

The score decides whether we ASK. It never decides what we RECORD.

The moment a confidence number sets state directly, the product silently mislabels people's job searches and "user-reported" becomes a fiction. Keeping the score on the asking side also prevents the prompt-flood failure that trains users to dismiss everything.

Corrections to the record

Two blockers asserted against the extension are false: Gmail's CSP does not block MV3 content scripts (they run in an isolated world), and DOM reading does not trigger Google's restricted-scope security assessment (that attaches to OAuth scopes). The real constraint is DOM fragility — and silent breakage is not inherent: an extension that extracts zero senders on a mail page can report that as a health signal. Ship the canary with the selectors.


7. How these features ship broken despite green tests

Prior art: WING-305 — a capability gate read features['x'].status while the server sent a top-level boolean, the spec's fixture encoded the same wrong shape, 97/97 tests passed, and the feature was 100% unreachable in production on both editions.

Predicted repeats:

  • Guilds: route omitted from SIMPLE_ROUTE_PATHS; capability read at the wrong JSON path; catalog absent from the packaged build while tests inject a full one.
  • Calendar: correct storage, wrong render — a UTC instant coerced through JS local-date handling, or an all-day deadline landing a day early, with fixtures generated by the same serializer that reads them.
  • Inbox: the status write never lands where the app reads it.

The gate (WING-355): boot the installed packaged artifact, point it at a verbatim captured production payload, derive both navs from SIMPLE_ROUTE_PATHS, click the surface in Flow mode — and assert the feature is visible with an EMPTY catalog. That last assertion is the one that separates "the flag is wired" from "the fixture was generous."

Consumer-authored fixtures are banned for these gates. A fixture written by the code under test can only confirm that code's own assumptions.


8. Unverified — these are NOT decisions

Recorded so nobody treats them as settled:

  • Meetup public-client OAuth. Implicit flow exists (client_id only, response_type=token, 1-hour token, no refresh token). Unknown: whether the callback registration accepts localhost or a custom scheme; the authoritative scope list for own-groups/own-RSVPs; whether Pro is required per authenticating user or only on the consumer owner. Spike: WING-386. Docs are not the receipt — a completed token exchange is.
  • iOS pending-local-notification cap — 64 historically; confirm against current platform docs.
  • Android exact-alarm policy under 12/13 (SCHEDULE_EXACT_ALARM / USE_EXACT_ALARM) and whether interview reminders qualify.
  • Chrome Web Store review burden for a mail.google.com host permission — manual review is expected, its cost is not measured.
  • Catalog seed cost — 40–100 ops hours is unit reasoning, not measured data. Treat the first 20 entries as a calibration batch and re-forecast.

Two API assumptions in this design process were already wrong from reading documentation alone. Verify at the producer.