-
released this
2026-09-20 23:05:39 -05:00 | 56 commits to main since this releasedocs/operations.mdnow 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_attendeesthrough 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 inCLAUDE.mdrather 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
againstCLAUDE.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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- An index of all sixteen live passes, at the top of Verifying against the live
-
released this
2026-09-20 23:05:38 -05:00 | 56 commits to main since this releaseget_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_gradesreturns a course total andlist_assignmentsreturns 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 reportinghas_feedbackon 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 singleinclude[]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.groupedis absent for a harder reason, and a test pins it. It silently
disablesorderandorder_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 whyinclude[]=assignment
is sent and joined. An unjoinable row carriescriterion_unresolvedand 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: falsemeans 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, whoseposted_atis
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_selfand 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 flaggedcomment_is_media_onlyrather than
reported as a blank remark; a quiz carriesquiz_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_onlydeclines 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 withunread_filter_not_appliedsaying 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/selfis used
instead, which is also the only route that reports read state.docs/canvas-plan.mdis 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 toCLAUDE.mdrather than dying with it,
anddocs/operations.mdnow points there for recording a session's absolute cap.A defect the tests caught before it shipped: a bounded read cannot report an
absence.sinceis sent to Canvas asgraded_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. Thesincecase is now tested first and says the
read was bounded. Generalised inCLAUDE.md.Canvas is at twelve tools and still has no write on its surface.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
released this
2026-09-11 07:27:20 -05:00 | 59 commits to main since this releaseEntry written retroactively on 2026-09-20; the code shipped and was tagged on 2026-09-11.
get_matchupsreported 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.scoreboardrequestsmMatchupScore
with noscoringPeriodId, and ESPN answers that withtotalPointsalone — a field it
leaves at zero until it finalises the scoring period. Sending the same view with a
scoringPeriodIdmakes ESPN addtotalPointsLive, the number its own app shows.
get_matchupsnow 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_scoresalready 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
scoringon every matchup row, saying what the two numbers are:final,live,
scheduled(a future week, so the zeros are placeholders) orunavailable(the week is
being played and ESPN would not give up its live totals).unavailableis 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 thatunavailablemust 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 rowunavailableevery week.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- The scoreboard asks for live totals.
-
released this
2026-09-10 22:35:43 -05:00 | 60 commits to main since this releaseget_box_scorenow 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_apiparses it; this server simply
never forwarded it. Every player in a box score now carriesstatsin 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
carryingpassingInterceptions: 0and 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
statsobject means something different — no line has been posted
yet — andgame_playedis 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
0.52.0 — the espnff tenant Stable
released this
2026-09-10 20:04:34 -05:00 | 62 commits to main since this releaseTenant #6:
espnff— ESPN fantasy footballOne 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_YEARis 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 onespn-apiand a test pinning
the patch point. An upstream change there would not crash; it would quietly take the
timeout back off.search_playersreturns every matchThe 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.confcarries a 180sproxy_read_timeoutwhere 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 printswrites=(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_secretscarries 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.47added, bringingrequestsandurllib3into an image that had
neither. Accepted deliberately — the four existing tenants use niquests/urllib3-future
and are untouched.- Both locks regenerated with the documented
--upgradeprocedure (~35 other pins moved).
That surfaced anyio 4.15 deprecatinganyio.abc.BlockingPortal, which starlette's
TestClient still imports; a narrowly scopedfilterwarningsignore names whose it is.
1611 tests green.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-26 19:51:18 -05:00 | 63 commits to main since this releaseA
message_idis 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_messageandunsubscribe_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_messagehad the answer since 0.16.0 and it was scoped to the wrong
question.expected_subjectmade 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: trueand 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_messageis 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, ondelete_messageand
unsubscribe_message, on both mail tenants. The subject the caller believes
themessage_idhas, 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 onexpunge_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'sexpected_recipientsare: 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_subjectmethod 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
refusalslug 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 readingrefusal: subject_mismatchknows to re-read the listing
without parsing English; a caller reading norefusalat all knows the
operation was attempted and the mailbox failed, which is a different thing to
tell the user.refusal: nullwould be read as neither.Carried on
MessageMismatchascode, defaulting tosubject_mismatchso
every pre-0.51.0 raise site stays meaningful untouched. -
A new log phase,
mail/guard. For the reasonimap/writeis separate
fromimap/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 whatget_messagewould have returned anyway.
Changed
-
The comparison no longer folds case, on every tool that carries it,
expunge_messageincluded — which had been folding since 0.16.0. Whitespace
is still normalised and is not a relaxation:decode_headercollapses 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_messagescopies 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 anRe:prefix.Re: XandXare different rows in a
listing, and a mailbox reliably contains both. -
move_messagecarries the check internally and does not offer it. The
argument is plumbing fordelete_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. Themove_messagetool'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_subjectis 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_messageandforward_messageare the obvious next candidates and
are deliberately not done here. Anything keyed only bymessage_idwith a
real consequence belongs behind the same guard;move_messagenames both
ends and is reversible in one call, andforward_messagewas left for the
release that can verify it against a live send rather than a fake. -
probes/m2_verify.pyhas been stale since 0.30.0 and now says so at the
top. Its sections 5 and 7 drivedelete_messagewithpermanent=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_enabledinside 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\Deletedis the check in that file with the
worst silent failure, and nothing has covered it since.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-26 10:56:10 -05:00 | 65 commits to main since this releaselist_discussion_entriesreturned"(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 clean200, 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-Danieland was
found, their reply did not and was not.The cause is one field on one payload. A
/viewnode names its author with
a bareuser_id;user_nameand the nesteduserobject belong to
/entries, which this client stopped calling in 0.49.0 for being twice the
price. The name in a/viewresponse lives in theparticipantsroster and
nowhere else. The client readuser_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/entriesrows —
correctly, that is whereuser_nameis — 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, andtests/test_canvas.pyeven documented the
agreement as a feature:_entrycarrieduser_nameand the nesteduser
"on purpose", as the fields the tool must drop. They are not on a/viewnode
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
-
authoris joined out of theparticipantsroster, which is where a
/viewpayload keeps names.user_nameis still preferred when a payload
carries one, so nothing regresses if this client ever reads/entriesagain.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 ownuser_iddoes not leave either: a Canvas user id addresses a
profile on a public course host. -
An unresolved author is
author: nullwithauthor_unresolved: true,
never a string. The thread also reportsunresolved_authorswith 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_selfon 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
falsethere answers the question wrongly instead of declining it, which is
this release's own defect one layer down. -
own_entry_count,own_reply_countandpeople_replied_toon the
thread.own_reply_countcounts only posts answering somebody else, and
people_replied_tocounts 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_selfrows. Pastmore_entriesthe user's own reply can sit at 101; a
tally of the returned rows would say zero and read as a fact.people_replied_tosays people, not classmates, on purpose —/view
names participants without their role, so the instructor is indistinguishable
from a student here. -
own_participation_unknownwith 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_sessionwas 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/viewfixture now matches the payload Canvas
sends — nodes carryuser_idand no name, and_viewbuilds 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_entrysays not to put a name
back on a node. -
The thread log line carries
unresolved_authors,identityandown.
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/viewpayload here rests on the failure report
(19 of 19 unresolved, which only the missing field explains) and on
/api/v1/users/selfbeing 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-25 20:43:00 -05:00 | 67 commits to main since this releaseCanvas 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 inCLAUDE.mdalready — 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 SAMEtopic_idasget_discussion, which
now names it on any topic that has replies.Built on
/viewrather than/entries, which is the measurement that
moved the design. One/viewcosts 0.1932 and returns the whole nested
tree;/entriescosts 0.3926 and returns only the top level. Half the
price for twice the content./entrieshangs arecent_repliesarray 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
depthandin_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-entrymessage_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) andCANVAS_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_postlock 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 answers403with a body of exactlyrequire_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.CanvasNeedsInitialPostsubclassesCanvasRefused, 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: truebesideneeds_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,pronounsandanonymous_idbeside each
post, plus aparticipantsroster covering people who never posted. All of
it is dropped at the client boundary anduser_nameis 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,
followingget_discussion.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-25 20:32:51 -05:00 | 68 commits to main since this releaseThe 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 fromp=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 thedmarc=
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_policywas 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_PROPERTYmatchesptype.property=valueand excludes parens, so nothing
ever looked inside one.dmarc_policy_unknowntherefore 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 trusteddmarc=verdict and 1,147 of
them carry the policy — 501p=none, 422p=quarantine, 224p=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-Policywas present on 0 of 1,147, which is the fact
0.47.0 had and the one it generalised from._dmarc_comment_tagsreadsp,spanddisout of the comment, and
lowercases them: Google writesp=QUARANTINE,_POLICY_NOTEkeys 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 overX-DMARC-Policy: it sits inside a
headeris_trustedhas already ruled on, so it carries the same boundary
check as the verdict it qualifies, whileX-DMARC-Policyhas 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_sourcereports 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'sp=quarantine, and not one of them says anything about
whoever wrote the message. A barepolicy: quarantinebeside a forwarded
message reads as the sender's defences.policy_domainand a sentence on
policy_notesay 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 publishp=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 domaind=paypal.comrather thanheader.from=, and 11
of 1,147 still arrive that way. Unread,evaluated_fromis empty and
evaluated_from_matches_headercomes back False — which is the signature
of a message carrying two From headers, asserted about ordinary mail from a
bank. Read ondmarconly, 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_NOTEno 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.pyon real Gmail mail: a direct message reports
policy: quarantine,policy_source: authentication-results,
policy_domain: duck.com; a relayed one reportspolicy_domain: simplelogin.cowith the assessment naming the policy as the relay's.
dmarc_policy_unknownis gone from both.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
0.47.0 Stable
released this
2026-08-25 18:45:29 -05:00 | 69 commits to main since this releaseThree 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 shippedget_message_security
with noassessmentat all.Fixed
-
dmarc_policy_nonewas reported on every Gmail message, whatever the
sender publishes.decideread(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 publishingp=reject. It contradicted
AuthResults.to_dicton the same payload, which correctly omitted the key.Absent and
noneare now two facts:dmarc_policy_unknownwhere nothing was
read,dmarc_policy_noneonly where a policy was read and isnone. The
authentication block sayspolicy_available: falsewith a note rather than
going silent, because an absent key reads as "nothing to say" and the reader
supplies their own prior. -
_parse_oneread the ARC instance tag as the authserv-id. RFC 8617 puts
i=Nin front of the identity, so on anARC-Authentication-Resultsthe
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 enteredmail/origin.py._parse_onetakes anarcflag andparse_arcreads those headers
properly. The flag rather than sniffing for a leadingi=: a plain
Authentication-Resultsis attacker-controlled untilis_trustedhas ruled
on it, and skipping ani=1there 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.pyover the
4,602-message Gmail archive: the i=1 record really isi=1; mail.protonmail.chwith no verdicts, 258 of 258. What the old parser also
hid is the i=2 record sealed bymx.google.comon the same messages,
carryingdkim,dmarc,spfandarc— 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_fromreadsAuthResults.dkimonly to confirm the relay
signedFrom, 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_signednow checks for one: passing, not from a relay, and aligned
with the recovered origin.Origin.verified_bynames the signer and
claimedbecomes 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.provenancedeliberately 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
historymail/aliases.pyis built on. -
Gmail's
get_message_securityreturned noassessment. 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.envand 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_unknownin place of
dmarc_policy_none,policy_available: falsein the authentication block,
and the assessment present with its relay attribution intact.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-