• v0.55.1 ee800b3da1

    joeyr released this 2026-09-20 23:05:39 -05:00 | 56 commits to main since this release

    docs/operations.md now opens its live-pass section with an index. Documentation
    only; no code changed.

    Changed

    • An index of all sixteen live passes, at the top of Verifying against the live
      account
      : probe name, check count, the date it last passed, and the condition that
      should trigger a re-run. The section below it runs to a thousand lines of dated records
      ordered by phase, so finding the pass that covers a path you are about to touch meant
      already knowing it as "G1" or "M4".
    • Four passes are marked as sending real mail, since that is the property worth
      knowing before running one rather than after.
    • Five things no pass proves are listed together — update_attendees through the tool
      layer, the Schedule-Tag conflict path, third-party MX delivery, Bcc to a distinct
      recipient, and a real mailto unsubscribe. Each was already stated in its own section,
      where an absent record reads as a clean bill of health. The three Canvas owes are
      pointed at in CLAUDE.md rather than copied.
    • The index states that probes/ is gitignored, which is the reason the numbered
      checklist in each entry is the only committed specification of what its probe asserts.
      Those checklists are the spec and must not be compressed into a pointer to a script that
      exists on one machine.

    Notes

    Nothing in the pass records was removed. A review looked for narrative that could be cut
    against CLAUDE.md's copies of the same findings and found roughly forty duplicated
    lines, almost all single sentences inside paragraphs that explain why a given check
    exists — G2's witness label, G3's per-observation connection, G7's fixture. Stripping
    those leaves instructions nobody can evaluate, which is how one gets simplified away
    later. The file shrinks properly only when the probes themselves are scrubbed and
    committed.

    Downloads
  • v0.55.0 ee800b3da1

    joeyr released this 2026-09-20 23:05:38 -05:00 | 56 commits to main since this release

    get_submission_feedback: the sentence half of a grade. C7, the last tool
    the Canvas build plan had left, and the only phase that was ever blocked by a
    calendar rather than by a decision — measured 2026-08-24, the endpoint worked and
    there were ZERO graded submissions across three courses, so there was nothing to
    read. Grading has started.

    get_grades returns a course total and list_assignments returns a per-assignment
    score. Neither carries a word of what the grader wrote, so "why did I lose those
    points" was the one coursework question this tenant could see the evidence of and
    not answer — the planner has been reporting has_feedback on items it could not
    open since C3.

    One request for a whole course. The bulk endpoint takes submission comments,
    their HTML twins and the rubric assessment in a single include[] set, so score
    and commentary are two calls rather than twelve. student_ids[] is omitted
    deliberately: Canvas answers the bare endpoint with the calling user's submissions
    and refuses a student who asks for anyone else's, so naming the account adds a
    parameter that can only make the request refusable.

    grouped is absent for a harder reason, and a test pins it. It silently
    disables order and order_direction — which would leave both accepted, ignored
    and invisible from the response, with the newest feedback quietly no longer landing
    on page one of a truncated read.

    A rubric assessment is unreadable on its own. Canvas returns it keyed by
    criterion id and nothing else — {"_1234": {"points": 4.5}} — while the
    descriptions live on the assignment's rubric, which is why include[]=assignment
    is sent and joined. An unjoinable row carries criterion_unresolved and a null
    name rather than the id dressed up as one. "blank" is a real Canvas rating id,
    not a null in disguise, and is resolved like any other.

    posted: false means WITHHELD, not empty. Canvas answers an unposted
    submission under a clean 200 with the score and the comments held back, so
    "nothing has been said" and "something has been said and not released" arrive in
    the same shape. Those rows are returned with a note saying so instead of being
    dropped for carrying no feedback — an ungraded submission, whose posted_at is
    also null, is not reported as withheld.

    Who wrote a comment is looked up, not inferred. A peer-reviewed assignment
    puts classmates in the same array, so "not the student's own, therefore the
    instructor's" is wrong rather than approximate. One roster request resolves it,
    made only when a comment exists to attribute; where it fails, author_is_instructor
    is ABSENT and the response says the role could not be confirmed. Same rule as
    is_self and for the same reason as 0.49.0's (unknown).

    Two more shapes that read as emptiness and are not. An audio or video comment
    arrives with no text at all and is flagged comment_is_media_only rather than
    reported as a blank remark; a quiz carries quiz_feedback_not_included, because
    per-question feedback lives in a Canvas API this server does not read — and both
    quiz engines are detected, since a New Quiz carries none of the classic signals.

    unread_only declines rather than emptying the list. Read state is reported by
    the single-assignment endpoint and not by the bulk one, so the filter is real on one
    path and refused on the other with unread_filter_not_applied saying which. It is
    measured BEFORE the filter runs: taken afterwards, a course where the user has read
    everything is indistinguishable from an endpoint that never reported read state, and
    "you are up to date" and "this filter did not run" are opposite answers.

    Narrowing to one assignment changes endpoint rather than parameters.
    assignment_ids[] on the bulk route has a standing report of returning no comments
    for an assignment the user has not submitted to, even where staff left them — the
    silent-emptiness failure this tenant keeps finding. /submissions/self is used
    instead, which is also the only route that reports read state.

    docs/canvas-plan.md is deleted. It was a queue that held only what was
    unbuilt and said to delete it when the last item was done — that was this tool.
    Its three outstanding LIVE checks moved to CLAUDE.md rather than dying with it,
    and docs/operations.md now points there for recording a session's absolute cap.

    A defect the tests caught before it shipped: a bounded read cannot report an
    absence.
    since is sent to Canvas as graded_since, so a windowed read comes
    back with a graded count of zero whether or not the course has ever been graded —
    and the empty-result note reached for "nothing has been graded yet" from a window
    that had excluded the evidence. The since case is now tested first and says the
    read was bounded. Generalised in CLAUDE.md.

    Canvas is at twelve tools and still has no write on its surface.

    Downloads
  • v0.54.0 8e14b88e45

    joeyr released this 2026-09-11 07:27:20 -05:00 | 59 commits to main since this release

    Entry written retroactively on 2026-09-20; the code shipped and was tagged on 2026-09-11.

    get_matchups reported 0-0 for every game that was still being played. On a Sunday
    evening with games half over, the one tool whose job is reporting the score answered that
    the games had not started yet — complete, confident and wrong.

    Fixed

    • The scoreboard asks for live totals. League.scoreboard requests mMatchupScore
      with no scoringPeriodId, and ESPN answers that with totalPoints alone — a field it
      leaves at zero until it finalises the scoring period. Sending the same view with a
      scoringPeriodId makes ESPN add totalPointsLive, the number its own app shows.
      get_matchups now fetches that for a week in progress only: one extra request, and none
      at all for a finished or future week.
    • 0-0 in progress and 0-0 before kickoff were the same two numbers, with nothing in the
      row to separate them. box_scores already read the live field, which is why the box
      score answered correctly while the scoreboard did not — but it costs three requests and
      builds every player on every roster, far too much to answer "what's the score".

    Added

    • scoring on every matchup row, saying what the two numbers are: final, live,
      scheduled (a future week, so the zeros are placeholders) or unavailable (the week is
      being played and ESPN would not give up its live totals).
    • unavailable is the point of the field. When the live fetch fails there is no honest
      score to report, and falling back to the scoreboard's own zeros would restore the exact
      defect — so the row says it could not be read rather than passing them off. The tool
      description spells out all four states, including that unavailable must never be
      described as "not started yet".
    • A BYE side counts as resolved, having no score that could be missing. Without that,
      the odd team out in an odd-sized league would mark its row unavailable every week.
    Downloads
  • v0.53.0 3de969cd38

    joeyr released this 2026-09-10 22:35:43 -05:00 | 60 commits to main since this release

    get_box_score now reports what each player actually DID, not just what they
    scored.
    Asked "did anyone on my team get a touchdown?", the server could only
    offer fantasy points, and the honest answer was that it could not tell — from a
    question the data settles outright.

    ESPN returns the real stat line and espn_api parses it; this server simply
    never forwarded it. Every player in a box score now carries stats in ESPN's
    own names — receivingReceptions, rushingYards, passingTouchdowns, and the
    rest of a 136-entry vocabulary.

    Only non-zero stats are present, and that is not a size optimisation. ESPN
    returns the entire vocabulary for every player, so a wide receiver arrives
    carrying passingInterceptions: 0 and ninety more like it. A column of zeroes
    reads as a measurement when it is really an absence, and it buries the handful
    of numbers that are real. A missing key therefore means it did not happen.

    An empty stats object means something different — no line has been posted
    yet — and game_played is what separates the two. The tool description says so,
    because "he did nothing" and "his game has not kicked off" are the same shape
    from here and only one of them is an answer.

    The stat line is read from the week that was asked for, which is pinned by a
    test: player stats are keyed by scoring period, and reading the wrong key
    returns a real stat line for the wrong week — indistinguishable from a correct
    answer unless you already know the numbers.

    Downloads
  • v0.52.0 4028c6a349

    joeyr released this 2026-09-10 20:04:34 -05:00 | 62 commits to main since this release

    Tenant #6: espnff — ESPN fantasy football

    One league, read-only, ten tools: get_league_info, list_teams, get_team_roster,
    get_team_info, get_standings, get_matchups, get_box_score, search_players,
    get_free_agents, get_league_activity.

    In the code and not deployed. It needs a Forgejo OAuth app, a stack service and a
    proxy host, none of which live in this repo.

    A library that answers the wrong question rather than refusing

    The HTTP, cookie auth and parsing are espn_api's (cwendt94). Every other provider in
    this project fails by refusing — a revoked password, an expired cookie, a 404 — and those
    failures announce themselves. This one has three paths that return a complete, confident,
    well-formed answer to a question nobody asked. All three are now guarded:

    • box_scores(week) ignores a future week and returns the current week's numbers. No
      error, no flag. Refused now, and refused before the request so ESPN never sees it.
    • free_agents(position=...) drops a position code it does not recognise and returns
      the unfiltered pool
      — position="rb" would read as a list of running backs and be a
      list of everybody. Validated against espn_api's own map, derived rather than retyped.
      Lower case is still normalised; a guess at what "RunningBack" meant would be inventing
      a filter.
    • A stale ESPN_SEASON_YEAR is served perfectly by ESPN. Every tool answers last
      season's question correctly and nothing goes red, because nothing is wrong with the
      request. No network failure could detect it, so preflight checks the calendar instead
      and warns — advisory, since reading a finished season on purpose is legitimate.

    Every espn_api request had no timeout

    The library issues bare requests.get(...) calls throughout, so a stalled connection to
    ESPN would have held an MCP tool call open indefinitely — nothing else in this stack gives
    up first. Fixed by replacing the one module attribute those calls go through, which is a
    monkeypatch on an internal, so it comes with a ceiling on espn-api and a test pinning
    the patch point. An upstream change there would not crash; it would quietly take the
    timeout back off.

    search_players returns every match

    The obvious implementation takes the first name containing the query, and it is wrong in
    exactly the case somebody searches — a surname shared by two players — with nothing in the
    response saying so. Every match comes back with its position, its NFL team and whoever in
    this league already holds it.

    Nothing is cached

    Every call rebuilds the whole league: three to five requests including the full
    professional player index. A waiver run rewrites half the rosters at once, so the failure
    mode of a cache here is a confident answer about a roster that changed twenty minutes ago.
    Two consequences: deploy/nginx/espnff.conf carries a 180s proxy_read_timeout where the
    other four carry 120s, and the healthcheck does not touch ESPN.

    Read-only by construction

    ESPN has a full write API — lineups, waiver claims, trades — and no code path here calls
    any of it. The boot summary prints writes=(none, no write tool exists on this tenant) as
    a constant, and a test fails on any tool name starting with a write verb.

    Also

    • _check_secrets carries values rather than names. It chose the credential value with a
      conditional on tenant name, which worked while every identity was an Apple ID or a mail
      address; this tenant authenticates with two cookies and has no address at all.
    • espn-api>=0.46,<0.47 added, bringing requests and urllib3 into an image that had
      neither. Accepted deliberately — the four existing tenants use niquests/urllib3-future
      and are untouched.
    • Both locks regenerated with the documented --upgrade procedure (~35 other pins moved).
      That surfaced anyio 4.15 deprecating anyio.abc.BlockingPortal, which starlette's
      TestClient still imports; a narrowly scoped filterwarnings ignore names whose it is.

    1611 tests green.

    Downloads
  • v0.51.0 42e6ccb0ab

    joeyr released this 2026-08-26 19:51:18 -05:00 | 63 commits to main since this release

    A message_id is the one argument in a mail call that nothing corroborates,
    and until this release three tools acted on it alone.
    It is a long opaque
    string a model picked out of a listing, and two adjacent rows differ by a
    handful of characters at the end. delete_message and unsubscribe_message
    took it and nothing else — so a handle picked from the row above the one meant
    was not a mistake either side could see. The server had no second fact to check
    it against, and the caller got back exactly what a correct call returns.

    expunge_message had the answer since 0.16.0 and it was scoped to the wrong
    question.
    expected_subject made the caller name what it was destroying, and
    the rule around it was: guard the irreversible branch, leave the reversible one
    cheap. That rule was not wrong about the recoverability. It was wrong about who
    could reach it — the undo needs somebody to NOTICE, and the caller that picked
    the wrong handle is exactly the caller who cannot.
    A wrong delete answers
    deleted: true and hands back a handle, and nobody looks for a message they do
    not know is missing. On Gmail the recoverability also has a clock on it: the Bin
    empties itself after about 30 days, so an unnoticed wrong delete becomes an
    unnoticed destruction.

    And unsubscribe_message is the one where the guard matters most, not least.
    Nothing in the mailbox changes, so there was nothing to gate it behind — but the
    act lands on a stranger's endpoint and there is no handle afterwards to undo it
    with. Every hygiene and authentication check that tool runs asks whether THIS
    sender may be contacted; run against a handle naming a different message, all of
    them are thorough answers to the wrong question. The checks pass on the
    newsletter the caller meant while the POST goes to whatever the adjacent row was.

    Added

    • expected_subject, required, on delete_message and
      unsubscribe_message, on both mail tenants.
      The subject the caller believes
      the message_id has, compared server-side against what the handle actually
      resolves to. It cannot be satisfied by trying harder: a caller holding a
      transposed handle holds a subject belonging to a different message.

      Required by the signature, not conditionally by another argument's value.
      A guard the caller may omit is not a guard, and optional is what a model
      leaves out.

    • expected_sender, optional, on those two and on expunge_message. A
      subject is not unique — two notices from the same building on the same day
      carry the same line, and against those the subject check passes on either
      handle. Compared as a bare address, lowercased, the way
      reply_all's expected_recipients are: a display name is presentation and
      differs by punctuation between listings, and refusing on that would train
      callers out of passing the argument at all.

      A value with no address in it is refused as a bad argument rather than
      reported as a mismatch. Both readings refuse, so the branch buys wording — but
      "that message is from X, not 'Accounts Payable'" sends a caller looking for a
      message that does not exist.

    • mcp_gateway/mail/guard.py, the comparison itself, shared. It was a
      _require_subject method on each client, duplicated, and two copies of a
      comparison rule is how two tenants come to disagree about what "the same
      subject" means. What is left on each client is the ENVELOPE fetch, which is
      genuinely per-protocol, and the tenant's own word for where a message lives —
      a Gmail message is in a LABEL, and telling a caller to re-read "the folder"
      names nothing it can act on.

    • A refusal slug on the response, alongside the sentence:
      expected_subject_required, subject_mismatch, sender_mismatch. Present
      only when a guard refused, so the field's presence is itself the signal. A
      caller reading refusal: subject_mismatch knows to re-read the listing
      without parsing English; a caller reading no refusal at all knows the
      operation was attempted and the mailbox failed, which is a different thing to
      tell the user. refusal: null would be read as neither.

      Carried on MessageMismatch as code, defaulting to subject_mismatch so
      every pre-0.51.0 raise site stays meaningful untouched.

    • A new log phase, mail/guard. For the reason imap/write is separate
      from imap/fetch: "how often is the guard refusing, and on what" is a
      question about the guard, unanswerable if the lines are mixed into a busy
      write path. It also spans tools that never write — an unsubscribe refusal is
      not an IMAP write and was being filed as one. No subject and no sender reach
      a log line, on any of them: the tool response carries both, and there it is
      exactly what get_message would have returned anyway.

    Changed

    • The comparison no longer folds case, on every tool that carries it,
      expunge_message included — which had been folding since 0.16.0. Whitespace
      is still normalised and is not a relaxation: decode_header collapses a
      folded header to one line, so the actual side arrives normalised whether
      anybody asked or not, and the expected side has to match that or a subject
      folded on the wire could never be matched at all.

      Case is a different argument. A model copying a subject out of
      list_messages copies its case with it, so a case difference means the
      subject was typed rather than copied
      — reconstructed from what the caller
      thinks the message says instead of read from what it does, which is precisely
      the caller the guard exists for. Nothing else is normalised: not punctuation,
      not an emoji, and not an Re: prefix. Re: X and X are different rows in a
      listing, and a mailbox reliably contains both.

    • move_message carries the check internally and does not offer it. The
      argument is plumbing for delete_message, which is what lets it run on the
      open connection against the resolved UID, before the COPY or MOVE — a
      check on its own connection would be answering about a mailbox that could
      still change underneath the write. The move_message tool's schema is
      unchanged.

    • Tool descriptions say why the field is not optional, in those words. The
      description is the only thing that tells a calling model to populate it
      honestly rather than perfunctorily, and each says what a refusal means: you
      addressed the wrong message, so re-read the listing — do not resubmit with
      the subject the error named.
      A caller that corrects its argument from the
      refusal has learned how to get past the guard and nothing else; the handle is
      still pointing at a message nobody asked about.

    Notes

    • This is a breaking tool-contract change. Any caller not passing
      expected_subject is refused. The only caller is Claude over MCP, which
      re-reads the schema on connect, so there is nothing to migrate — but the four
      tools' signatures changed and that is what this entry is for.

    • move_message and forward_message are the obvious next candidates and
      are deliberately not done here. Anything keyed only by message_id with a
      real consequence belongs behind the same guard; move_message names both
      ends and is reversible in one call, and forward_message was left for the
      release that can verify it against a live send rather than a fake.

    • probes/m2_verify.py has been stale since 0.30.0 and now says so at the
      top.
      Its sections 5 and 7 drive delete_message with permanent=True, an
      argument removed five releases ago, so the run dies before section 6. That is
      recorded rather than quietly fixed because restoring it means arming
      mail_expunge_enabled inside a probe — a decision to take deliberately. What
      it no longer proves is the part that matters: UID-scoped destruction against
      a neighbour already flagged \Deleted is the check in that file with the
      worst silent failure, and nothing has covered it since.

    Downloads
  • v0.50.0 b23f34d03f

    joeyr released this 2026-08-26 10:56:10 -05:00 | 65 commits to main since this release

    list_discussion_entries returned "(unknown)" as the author of every post
    in a live thread, and it looked like an answer.
    Nineteen entries in a graded
    introductions discussion, a clean 200, no error, no flag — and a reader with
    no way to tell "Canvas was asked and had no name" from "the lookup never
    happened". The second one was true. The connector then reported the user's
    required replies as 0 of 2 done when 1 was, stated as fact, because the
    only attribution left was the prose: their initial post ended -Daniel and was
    found, their reply did not and was not.

    The cause is one field on one payload. A /view node names its author with
    a bare user_id; user_name and the nested user object belong to
    /entries, which this client stopped calling in 0.49.0 for being twice the
    price. The name in a /view response lives in the participants roster and
    nowhere else. The client read user_name, got nothing every time, and had a
    default sitting there to make it presentable.

    Two habits let it through, and both are worth naming.

    A probe measured one payload and the design generalised to the other. C6
    asked "what does an entry carry for identity" against /entries rows —
    correctly, that is where user_name is — and the same session decided /view
    on cost. Nothing re-asked the identity question of the payload that won.
    A measurement is about the request that produced it. When a probe changes
    which endpoint ships, every finding taken from the other one is unverified
    again.

    The fixture agreed with the code because the same person wrote both. This
    project's oldest failure mode, and tests/test_canvas.py even documented the
    agreement as a feature: _entry carried user_name and the nested user
    "on purpose", as the fields the tool must drop. They are not on a /view node
    at all. 1399 tests were green against a payload Canvas does not send.

    And the default made it silent. "(unknown)" is a plausible-looking string,
    so a wrong answer built on it carries no seam. A fallback that cannot be
    distinguished from a result is worse than no fallback.

    Fixed

    • author is joined out of the participants roster, which is where a
      /view payload keeps names. user_name is still preferred when a payload
      carries one, so nothing regresses if this client ever reads /entries again.

      The privacy decision is unchanged. The roster is read, reduced to
      {id: display_name}, and dropped — it covers people who never posted and
      carries avatars and profile links, and none of that leaves the client. The
      entry's own user_id does not leave either: a Canvas user id addresses a
      profile on a public course host.

    • An unresolved author is author: null with author_unresolved: true,
      never a string. The thread also reports unresolved_authors with a note
      saying the authorship is UNKNOWN rather than anonymous — do not attribute
      those posts, and do not count them as anybody's reply.

    Added

    • is_self on every entry, from the account's own Canvas id rather than
      from the text. "Have I already replied" is the question a graded discussion
      forces, and text-matching answers it wrongly the moment somebody does not
      sign a post.

      It is absent, not false, when the account could not be identified. A
      false there answers the question wrongly instead of declining it, which is
      this release's own defect one layer down.

    • own_entry_count, own_reply_count and people_replied_to on the
      thread. own_reply_count counts only posts answering somebody else, and
      people_replied_to counts distinct people — because "reply to two
      classmates" is not satisfied by two replies to one of them, nor by continuing
      your own sub-thread, and a withdrawn post is not a completed one.

      Counted across the whole tree rather than the returned slice, which is
      the reason the client derives them instead of leaving the caller to tally
      is_self rows. Past more_entries the user's own reply can sit at 101; a
      tally of the returned rows would say zero and read as a fact.

      people_replied_to says people, not classmates, on purpose — /view
      names participants without their role, so the instructor is indistinguishable
      from a student here.

    • own_participation_unknown with a note when the account could not be
      identified: none of those counts are present, and the note forbids the
      fallback that caused all of this — reading the prose for a name.

    • _self_user_id — one /api/v1/users/self, cached for the process. It
      runs after the thread is read and swallows every failure, including an
      expired session: ordered the other way, a bad answer from it would take down
      a tool that reads threads perfectly well without it. check_session was the
      obvious home and was declined — boot's request exists to prove the account
      can see coursework, and failing a boot over a field only one tool reads
      trades a working tenant for a convenience.

    Changed

    • tests/test_canvas.py's /view fixture now matches the payload Canvas
      sends
      — nodes carry user_id and no name, and _view builds the roster
      the names have to be joined out of. The old fixture is the reason this was
      invisible to the suite, so the docstring on _entry says not to put a name
      back on a node.

    • The thread log line carries unresolved_authors, identity and own.
      The first is the number that would have named this defect from the log alone
      — 19 of 19, for a release — on a line nobody had a reason to read. Counts
      only: no id and no name, as before.

    Not verified live

    The fix is proved against the suite and not against Canvas. The session
    cookie this tenant runs on lives in a Swarm secret; there is no working local
    credential, so the shape of a /view payload here rests on the failure report
    (19 of 19 unresolved, which only the missing field explains) and on
    /api/v1/users/self being an unmeasured request. Read the boot log and one
    thread through the deployed connector before calling this closed
    — this
    project's standing rule that a live pass finds what a green suite cannot has
    now been paid for twice on this tool.

    Downloads
  • v0.49.0 57f3f23738

    joeyr released this 2026-08-25 20:43:00 -05:00 | 67 commits to main since this release

    Canvas C6: list_discussion_entries. The tenant could read what a
    discussion ASKS and not a word of what anyone SAID — so a graded discussion
    that requires replying to two classmates was an assignment it could describe
    and not help with. The replies are the half that was missing.

    This plan declined discussions three times in one sentence and was wrong all
    three times.
    "Large free-text surfaces with no natural bound" was measured
    against pages (reversed in 0.43.0), against prompts (reversed in 0.44.0), and
    now against threads: the busiest thread on this account is 18 posts and 12,987
    characters, smaller than the syllabus this tenant already returns. The
    durable lesson is in CLAUDE.md already — measure a surface before declining
    it — and this is its third instance rather than a new one.

    Added

    • list_discussion_entries(course_id, topic_id) — every reply in one
      thread, in thread order. Takes the SAME topic_id as get_discussion, which
      now names it on any topic that has replies.

      Built on /view rather than /entries, which is the measurement that
      moved the design.
      One /view costs 0.1932 and returns the whole nested
      tree; /entries costs 0.3926 and returns only the top level. Half the
      price for twice the content. /entries hangs a recent_replies array off
      each entry that carried every child on the thread it was measured against —
      and "recent" is the API's word, not a promise. A tool built on it looks
      correct on a quiet thread and silently loses replies on a busy one.

      The tree is flattened, carrying depth and in_reply_to. A model
      answering "who replied to whom" from nested JSON has to hold the shape in
      mind; a flat list in thread order where each row names what it answers states
      the same relationship outright.

      Three truncations, reported separately, because they have different
      fixes: more_entries (thread longer than 100), deeper_replies (nested
      below 3), and per-entry message_truncated. A single flag would say
      something was missing without saying whether it was a person, a
      sub-conversation, or a paragraph.

    • CANVAS_THREAD_MAX_ENTRIES (100) and CANVAS_THREAD_MAX_DEPTH (3). A
      thread is the one Canvas surface whose size is a class's rather than a
      field's, so capping text bounds nothing. Depth is 3 rather than the
      observed 2 deliberately
      : every topic on this account is
      discussion_type: threaded, so Canvas imposes no ceiling and the observed
      depth is one class's habits rather than a limit.

    Fixed

    • A require_initial_post lock was being reported as a permission problem,
      and the advice was wrong in a way that costs someone an email to their
      instructor.
      A discussion set to hide replies until the student posts their
      own answers 403 with a body of exactly require_initial_post — measured
      against the one topic on this account that still has the lock closed. The
      shared 403 branch answered that with "this account is not permitted to read
      that": true about the request, false about the account, and the actual fix is
      thirty seconds of the student's own typing.

      CanvasNeedsInitialPost subclasses CanvasRefused, so every existing
      caller stays correct — the session is fine and the request was refused, which
      is what the parent promises — and only the advice changes. Responses carry
      needs_initial_post: true beside needs_reseed: false. The token is matched
      whole rather than by substring, and the body is never echoed into the error
      message
      : a 403 body on another path is an unknown quantity and error text
      travels.

    Privacy

    • Classmates' names are kept; their profiles are not. Canvas returns
      avatar_image_url, html_url, pronouns and anonymous_id beside each
      post, plus a participants roster covering people who never posted. All of
      it is dropped at the client boundary and user_name is kept, because a
      graded discussion asks the student to reply to named classmates and a tool
      that anonymises them returns a draft the user re-personalises line by line.

      Stated plainly because the reverse gets claimed later: this is a small
      reduction. The sensitive material in a discussion thread is the posts, and
      the name is attached to those either way. The boundary that does the real
      work is that entry text is never logged — counts, lengths and ids only,
      following get_discussion.

    Downloads
  • v0.48.0 d252416be5

    joeyr released this 2026-08-25 20:32:51 -05:00 | 68 commits to main since this release

    The DMARC policy was there the whole time, in the same header the "could not
    be read" note was printed beside.
    0.47.0 split absent from p=none, which
    was right, and then explained the absence as a property of the provider —
    "iCloud stamps one, Gmail does not". Gmail stamps it inside the dmarc=
    clause. The parser was not reading that shape, and the note turned a parsing
    gap into a documented fact about Google, which is the kind of wrong answer that
    stops anyone looking again.

    Fixed

    • dmarc_policy was empty on every Gmail message, and the policy was in the
      clause.
      Google writes it as an RFC 8601 comment on its own verdict:

      dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=example.org
      

      _PROPERTY matches ptype.property=value and excludes parens, so nothing
      ever looked inside one. dmarc_policy_unknown therefore fired on 100% of
      that tenant's decisions — the replacement warning landing exactly where the
      one it replaced had, for a different reason.

      Measured 2026-08-25, probes/probe_dmarc_policy.py, over the 4,602-message
      Gmail archive: 1,147 messages carry a trusted dmarc= verdict and 1,147 of
      them carry the policy
      — 501 p=none, 422 p=quarantine, 224 p=reject.
      So on 56% of them the honest answer was a policy stronger than the weak
      case a reader supplies for themselves, and on the other 44% it was the weak
      case, stated. X-DMARC-Policy was present on 0 of 1,147, which is the fact
      0.47.0 had and the one it generalised from.

      _dmarc_comment_tags reads p, sp and dis out of the comment, and
      lowercases them: Google writes p=QUARANTINE, _POLICY_NOTE keys on the RFC
      spelling, and a straight pass-through would have fallen through to
      "Unrecognised published DMARC policy" across the whole tenant while looking
      like it had read the value.

    • The two records now have a precedence rule, and it is the one that cannot
      be forged.
      The clause comment wins over X-DMARC-Policy: it sits inside a
      header is_trusted has already ruled on, so it carries the same boundary
      check as the verdict it qualifies, while X-DMARC-Policy has no authserv-id
      and nothing here can distinguish Apple's from one a sender wrote. 0 of 1,168
      messages across both tenants carry both, so this is a rule against a future
      provider rather than a resolution of an observed conflict.
      dmarc_policy_source reports which record answered.

    • A reported policy now names the domain it belongs to. This is the one new
      way populating the field could mislead, and it is closed in the change that
      populates it. The policy is the evaluated domain's, and under a relay that
      is the forwarder: all 258 relayed messages on the archive report
      SimpleLogin's p=quarantine, and not one of them says anything about
      whoever wrote the message. A bare policy: quarantine beside a forwarded
      message reads as the sender's defences. policy_domain and a sentence on
      policy_note say whose it is.

      summarise's relayed branch has existed since 0.45.0 and had never fired
      on Gmail
      , because the value it guards was always empty there. It fires now.

    • sp= is reported, never substituted. 128 of 1,147 publish p=none sp=quarantine. Which applies depends on whether the From domain is the
      organisational domain or a subdomain of it, which needs the public suffix
      list — a dependency and a periodic data update this module does not carry —
      so both are reported and neither is chosen. dis= is reported separately as
      what the server actually did, which is not what the domain asked for.

    • The legacy bare d= DMARC domain was unreadable. Google's older clauses
      name the evaluated domain d=paypal.com rather than header.from=, and 11
      of 1,147 still arrive that way. Unread, evaluated_from is empty and
      evaluated_from_matches_header comes back False — which is the signature
      of a message carrying two From headers, asserted about ordinary mail from a
      bank. Read on dmarc only, and with comments stripped first: a bare-tag scan
      across every clause would start lifting values out of the free text in an SPF
      comment, which the sending domain's own name shapes.

    • _POLICY_UNKNOWN_NOTE no longer names the wrong cause. It described the
      parser's blind spot as something Gmail does not do. Both providers report the
      policy now, so the branch is left for the cases that are honestly unknown:
      nothing evaluated DMARC, or it was evaluated without a policy recorded.

    Verified

    • probes/probe_dmarc_policy.py, read-only, both tenants, 2026-08-25 — the
      counts above. Like every probe here it is gitignored, so they are a dated
      record against a named sample rather than something re-derivable from this
      repo.
    • probes/probe_policy_live.py on real Gmail mail: a direct message reports
      policy: quarantine, policy_source: authentication-results,
      policy_domain: duck.com; a relayed one reports policy_domain: simplelogin.co with the assessment naming the policy as the relay's.
      dmarc_policy_unknown is gone from both.
    Downloads
  • v0.47.0 85e904b975

    0.47.0 Stable

    joeyr released this 2026-08-25 18:45:29 -05:00 | 69 commits to main since this release

    Three defects in how a message's authentication is reported, all of them in
    the direction of a confident wrong answer.
    One was firing on 100% of Gmail
    messages; one would have made any future survey of the ARC chain wrong the same
    way the last one was; the third is a check that was never wired up. A fourth
    turned up while fixing them: the Gmail tenant shipped get_message_security
    with no assessment at all.

    Fixed

    • dmarc_policy_none was reported on every Gmail message, whatever the
      sender publishes.
      decide read (results.dmarc_policy or "none"), which
      collapses absent into p=none. The policy is recovered from iCloud's
      X-DMARC-Policy; Gmail stamps no equivalent, so it is empty for every
      message on that tenant — and the warning then asserted the weakest possible
      policy for senders publishing p=reject. It contradicted
      AuthResults.to_dict on the same payload, which correctly omitted the key.

      Absent and none are now two facts: dmarc_policy_unknown where nothing was
      read, dmarc_policy_none only where a policy was read and is none. The
      authentication block says policy_available: false with a note rather than
      going silent, because an absent key reads as "nothing to say" and the reader
      supplies their own prior.

    • _parse_one read the ARC instance tag as the authserv-id. RFC 8617 puts
      i=N in front of the identity, so on an ARC-Authentication-Results the
      authserv-id is the second clause and every verdict lands a clause out. A
      survey run through it reports "an authserv-id and nothing else" about a
      header full of results — which is how that claim entered mail/origin.py.

      _parse_one takes an arc flag and parse_arc reads those headers
      properly. The flag rather than sniffing for a leading i=: a plain
      Authentication-Results is attacker-controlled until is_trusted has ruled
      on it, and skipping an i=1 there would let a sender write the receiving
      server's own identity into their message.

      origin.py's claim survives the correction, and is now measured with a
      parser that could have disproved it.
      probes/probe_origin_dkim.py over the
      4,602-message Gmail archive: the i=1 record really is i=1; mail.protonmail.ch with no verdicts, 258 of 258. What the old parser also
      hid is the i=2 record sealed by mx.google.com on the same messages,
      carrying dkim, dmarc, spf and arc — Google's verdicts about the
      relay, which read as proof of the origin if the instance number is missed.
      Parser and a raw regex now agree at 258; before the fix the parser said 0.

    • A surviving original DKIM signature was never looked for.
      _relay_signed_from reads AuthResults.dkim only to confirm the relay
      signed From, but the receiving MTA verifies every signature on a message
      — and a forwarder that only adds headers does not break the original
      sender's. Such a signature would make the origin a cryptographic verdict
      rather than the relay's claim, lifting the restriction the module is built
      around.

      _origin_signed now checks for one: passing, not from a relay, and aligned
      with the recovered origin. Origin.verified_by names the signer and
      claimed becomes False.

      It fires on nothing, and that is the finding. 258 of 258 relayed messages
      carry the relay's signature and nothing else — not one aligned original
      signature, and none from any non-relay domain at all. SimpleLogin re-signs
      what it forwards. So the claim/verdict distinction stands on evidence rather
      than on inference, a gate tiered on origin authentication still has no input,
      and the path is kept because it can only ever raise confidence, only ever on
      a signature the receiving server verified. If a relay stops re-signing, the
      next message says so instead of the next survey.

      provenance deliberately stays "relayed" rather than gaining a
      relayed_verified: the alias index filters on that exact string, and a new
      one would have silently dropped every better-evidenced message out of the
      history mail/aliases.py is built on.

    • Gmail's get_message_security returned no assessment. iCloud has
      carried it since 0.23.0. It is the sentence that stops four green ticks being
      read as "this message is legitimate", and it is also where the relay caveats
      live — that a relayed message's verdicts belong to the forwarder, and that a
      policy read off such a message is the forwarder's. Gmail is the tenant where
      56% of mail is relayed, so it needed it most and had it least. Every other
      key was present and correct, which is why the gap was invisible.

      Under a relay the unknown-policy qualifier is deliberately suppressed: the
      pass being qualified is the relay's, so naming "the policy this domain
      publishes" would point at the wrong domain while sounding like the sender.

    Verified

    • probes/probe_origin_dkim.py, read-only, both tenants, 2026-08-25. Like
      every probe here it is gitignored — they read .env and print real account
      data — so the counts above are a dated record against a named sample, not
      something re-derivable from this repo.
    • Live on real relayed Gmail mail: dmarc_policy_unknown in place of
      dmarc_policy_none, policy_available: false in the authentication block,
      and the assessment present with its relay attribution intact.
    Downloads