nikclayton

Mastodon: https://mastodon.social/@nikclayton

People have forgotten what code review is for.

Code review is an asynchronous alternative to pair programming. Like pair programming the goal is that more than one person knows how the code works, understands the design, and it acts as a check-of-last-resort that the design is sound and the code implements the design.

So if you've been sent a multi-thousand line diff to review you're perfectly within your rights to tell the author to book a meeting with you so they can explain the code.

If done well this is much faster than repeatedly round-tripping code reviews in whatever tool you're using.

After yesterday's post about finding snowshoe routes using Swiss OpenData and QGIS I had one open question: how to also filter the routes by their minimum elevation?

As winter turns to spring and the snow starts melting this is necessary to find routes that probably still have snow on them.

The data from opendata.swiss has the route geometry, and various pre-existing fields for the route length, maximum ascent, and so on, but there is no direct field that contains the route's minimum elevation, so it cannot be used directly in a filter.

Assuming that this is the sort of thing that must be both possible and straightforward, I asked the QGIS community on the Fediverse. @tlohde@fediscience.org was kind enough to reply, and this post is adapted from their suggestions.

The rest of this post assumes you have already followed the steps in Finding snowshoe routes using Swiss OpenData and QGIS.

Outline

We are going to:

  1. Create a new column in the snowshoe data to contain the minimum elevation of each route.
  2. Apply a QGIS function, z_min, to populate this column.
  3. Update the existing filter to exclude routes with a minimum elevation below 1,500 metres.

Creating and populating the column

  1. Open the “Field calculator” for the snowshoe attribute table.
    1. Open the layer menu by right clicking the “schneeschuhwanderwege_2056.gpk” layer in the layer pane (bottom left)
    2. From the menu click “Open Attribute Table” to open the attribute table pane below the map.
    3. Open the “Field Calculator by clicking the “Edit” button and then the “Open field calculator” button in the toolbar above the attribute table open-attribute-table.png
  2. Create a new “min_elev” column in the data.
    1. Ensure “Create a new field” is checked.
    2. Enter “min_elev” in the “Output field name” field
    3. Enter “z_min($geometry)” in the “Expression” field
    4. Click “OK” field-calculator-min_elev.png

This has created a new column in the snow show data called “min_elev”, with a value calculated as the lowest Z point (elevation) in the route geometry for that row.

You can verify this in the attribute table. Scroll the table to the right and you will see the new min_elev column at the end.

Exclude routes below 1,500 metres

  1. Click the “Edit” button in the attribute pane to disable editing.
  2. Open the layer menu by right clicking the “schneeschuhwanderwege_2056.gpk” layer in the layer pane.
  3. Choose “Filter...” to open the Query Builder.
  4. Click the “Test” button.
    1. If you are using the query from yesterday you will see a dialog message, “The where clause returned 39 row(s)”. If you are using a different query the number of rows will be different.
    2. Click OK to dismiss the dialog.
  5. Edit the query to add a final clause, AND min_elev >= 1500. query-builder-min_elev.png
  6. Click the “Test” button again.
    1. If you where using the query from yesterday you will see a dialog message, “The where clause returned 9 row(s)”. So 30 routes that dip below 1,500 metres have been removed. If you were using a different query you will see a different number of results.
    2. Click OK to dismiss the dialog.
  7. Click OK to dismiss the Query Builder

Switzerland's great for winter activities, including snowshoeing. But finding somewhere to go can be frustrating.

Sites like SchweizMobil have a list of routes and useful information; length, total ascent/descent, route maps, and so on. But support for searching for routes that meet specific critieria is lacking.

In my case I wanted to find routes of between 5 and 12km, up to 300m of ascent, and taking no more than 4 hours. I'd like to see this on a map (so I can get a rough idea of where in the country they are), and quickly click between them as part of making a decision.

SchweizMobil doesn't support this sort of search, and you're left to scroll through routes, open them in different tabs, and eyeball some of the data to decide whether the route is suitable.

There's a better way. opendata.swiss provides all the necessary data under a range of licenses.

This data can be loaded in to QGIS, an open source desktop geographic information system (GIS) for displaying and manipulating map and related data.

Together this allows you to create your own personalised map of suitable routes.

At the end of this process you will be able to see a route on the map, including the elevation profile, like this:

elevation-pane-active-profile.png

Install and run QGIS

Install QGIS by following the instructions at the download page at the QGIS Web Site.

Launch QGIS after installation.

Install QGIS plugins

QGIS supports plugins that provide additional functionality, and we will use two:

  • QuickMapServices
  • Swiss Geo Downloader

Both of these plugins make it easy to load data from external sources directly into QGIS.

QuickMapServices displays data from OpenStreetMap directly in QGIS. We will use this to provide the map of Switzerland and routing.

Swiss Geo Downloader allows you to search for and import Swiss OpenData in QGIS.

There are other ways of accomplishing both of these tasks, the plugins make it straightforward.

To install these plugins:

  1. From the QGIS menu choose “Plugins > Manage and Install Plugins...”.
  2. Search for “QuickMapServices”, select it, and click “Install Plugin”.
  3. Search for “Swiss Geo Downloader”, select it, and click “Install Plugin”.
  4. The plugins dialog should look like this. The exact list of installed plugins may differ depending on the version of QGIS you have installed, but “QuickMapServices” and “Swiss Geo Downloader” should both be listed and checked. plugins-dialog.png
  5. Close the “Plugins” dialog.

Use OpenStreetMap as the base layer

  1. From the QGIS menu choose “Web > QuickMapServices > OSM > OSM Standard”.
  2. A world map should appear in QGIS, and the “OSM Standard” layer should appear in the “Layers” pane at the bottom left.
  3. Zoom in on Switzerland. It should look like this: switzerland-base-layer.png

Import snowshoe routes

  1. From the QGIS menu choose “Plugins > Swiss Geo Downloader > Swiss Geo Downloader”.
  2. You may be prompted to switch to the Swiss coordinate reference system, LV95. Choose “Yes”.
    1. If you are not already zoomed in to Switzerland the map will become very distorted. This is normal. The distortion will disappear when you zoom in on Switzerland
  3. A “Swiss Geo Downloader” window pane will open, with a search bar at the top.
  4. Enter “snowshoe” in the search bar.
  5. Choose the “Snowshoe trails” dataset from the results. Section 3 of the window should show “schneeschuhwanderwege_2056.gpkg” downloading-snowshoe-routes.png
  6. Click “Download”, and choose a directory when prompted.
  7. The “schneeschuhwanderwege_2056.gpkg” layer should appear in the “Layers” pane downloaded-snowshoe-routes.png

Improve visibility of the routes

The snowshoe routes may be difficult to view on the map. To improve their visibility:

  1. Double-click the “schneeschuhwanderwege_2056.gpkg” layer, to open the “Layer Properties” dialog.
  2. Adjust the colour, width, and other properties. For example, click “effect neon” and set the width to “1mm” to quickly make the routes stand out. layer-properties-symbology-neon.png
  3. The available routes should now be much more visible on the map: layer-properties-neon-applied.png

Filtering the routes

The map is currently showing all ~ 200 routes. To filter them to your own criteria:

  1. Right click the “schneeschuhwanderwege_2056.gpkg” layer and choose “Filter...” from the menu to open the “Query Builder” dialog.
  2. Use this to build a query for the routes you care about.

The query builder field names are in German, and have the following meanings:

  • NameR: String, route name
  • NrR: Number, route number (e.g., 101 is SchweizMobil route 101)
  • BeschriebR: String, route display name
  • LaengeR: Number, length in kilometres, e.g., 8.1
  • HoeheAufR: Number, ascent in metres
  • HoeheAbR: Number, descent in metres
  • ZeitStZiR: Number, estimated time in minutes, e.g., 225 = 3h45m?
  • TechnikR_en: String, difficulty level (english), e.g., “blue”

For example, the following query filters to routes that are:

  1. Between 5km and 12km
  2. Have a maximum ascent of 300m
  3. Take 4 hours (4 * 60 = 240 minutes) or less
LaengeR >= 5 AND LaengeR <= 12 
AND HoeheAufR <= 300
AND ZeitStZiR <= 240

query-builder.png

After applying the filter the map will look like this (your map will look different if you used different filter criteria).

filtered-routes.png

View an elevation profile

QGIS can display elevation profiles for routes.

To activate this feature:

  1. Choose “View > Elevation Profile” from the menu.
  2. The “Elevation Profile” pane will open at the bottom of the display. elevation-panel-after-open.png
  3. Click the small “Capture Curve from Feature” button in the “Elevation Profile” pane. elevation-panel-capture-curve-from-feature.png
  4. On the map, find the route you want to see the elevation profile for.
  5. Click the route.
  6. The elevation profile for the route will appear. elevation-pane-active-profile.png

Click a route to open a web page

The “schneeschuhwanderwege_2056.gpkg” data also contains URLs for each route. QGIS can be configured to open that URL if you click the route.

  1. Right click the “schneeschuhwanderwege_2056.gpkg” layer and choose “Properties...” from the menu to open the “Layer Properties” dialog.
  2. Choose “Actions” from the list of properties on the left of the dialog.
  3. Click the green “+” button to open the “Add New Action” dialog.
  4. Use the following action settings:
    1. Type: “Open URL”
    2. Description: “Open SchweizMobil”
    3. Action Scopes: Check “Canvas”, leave other options unchecked
    4. Action Text: [% ChmURL_en %]
      1. This will open the English page. Other options are [% ChmURL_de], [% ChmURL_fr], [% ChmURL_it], or [% ChmURL_rm] for German, French, Italian, and Romansh respectively.
  5. Click “Ok” twice to dismiss both dialogs.

You can now enable the action in the QGIS toolbar.

add-new-action-dialog.png

Suppose you have to write some documentation. It might be a tutorial, a how-to guide, or an explanation of a topic (see Diátaxis for more on those).

Challenges when doing this include:

  • Deciding how to structure the document.
  • Deciding when the document is complete.
  • Over-stuffing the document with information that is not relevant to the reader at that time.
  • Ensuring the reader has the necessary prerequisite knowledge to understand the material.

To combat those problems start the document with a “Synopsis” section that consists of:

  • 1-n paragraphs that provide a very brief introduction to the topic. That might be an overview of the topic, or perhaps a description of the specific problem the documentation will help you solve.
  • A list of 0-n bullet points that start “Before reading this document you should:“.
    • Each bullet point should list a task to complete, with a link to material that explains how the reader can complete the task. The task might be an activity they need to complete, or a document they should read before this one.
  • A list of 1-n bullet points that start “After reading this document you will know:”

I have found this to be invaluable when organising your thoughts.

  1. If you cannot write a brief introduction to the topic that is an indication you either do not understand it well enough to write about it, or the topic is too complex to cover in a single document and should be separated into smaller documents.
  2. The “... you will know” bullet points serve several functions:
    1. They're your “to-do list” of things to write about in the document. You know the document is complete / ready for editing when you have ticked off each bullet point.
    2. If you find yourself writing more than ~ seven bullet points that's a good sign the document is going to be too complex, and should be broken in to smaller documents.
  3. Similarly, the “... you should:” bullet points have several functions:
    1. They allow you to be explicit about what is not covered in the document.
    2. They clearly direct the reader to the other material.
    3. They highlight material that may be missing. If you find yourself writing an entry in this section but do not have documentation that tells the user how to complete this task you have discovered another document you need to write.

This works whether you are writing a stand-alone document or one that is part of a collection.

I first adopted this technique when I was co-editing the 2nd edition of the FreeBSD Handbook, and although that is long in the past, today's handbook continues the tradition.

The synopsis for the “Wayland” chapter is a good example. It starts:

An installation of FreeBSD using bsdinstall does not automatically install a graphical user interface. This chapter describes how to select, install, and configure a Wayland compositor, which provides a graphical environment.

Before reading this chapter, you should know:

After reading this chapter, you will know:

  • How to configure FreeBSD to host a Wayland graphical environment.
  • How to install and configure a Wayland compositor.
  • How to run programs designed for the older X Window System.
  • How to configure remote desktop access to a Wayland graphical environment.

In that example:

  • The introductory paragraph provides context for the reader.
  • The “... you should know:” bullets clearly explain prerequisite knowledge, and direct the reader to the right place to learn that knowledge.
  • The “... you will know:” bullets tightly scope the content of the document, and allow the reader to rapidly determine whether this document is going to be helpful for whatever task they are carrying out.

Inspired by a discussion in the joinmastodon.org Discord.

Someone's writing an app processing incoming HTTP requests with a Content-Type header. Specifically, they want to handle the case where the content type is application/json.

They noticed some clients are sending an unnecessary charset=utf-8 parameter in the content type, and wondered how they should properly handle this parameter.

They're doing this as a learning exercise so wanted to avoid leaning on an existing library if possible.

The discussion went back and forth a little bit between validation and parsing without really getting in to the meat of it, it's too long for a chat message post, and I'm not aware of a good discussion of the topic elsewhere, hence this post.

What to do?

The difference between validation and parsing

I use the terms to mean the following.

You validate incoming data to decide if your code should continue on the happy path, or branch to an error handler because the data is invalid. The incoming data will not be used again.

You parse incoming data to validate it, and to extract the information to a different type for use elsewhere in the program.

To use Pachli as an example, the code validates the content type of incoming data as application/json because if it's not then an error has occurred. The content type is not used after that.

Pachli parses a server's version string to a dedicated Version type to hold a semantic version because the version information is used elsewhere in the code to make decisions.

Parsing to the dedicated type once means the rest of the code knows the version is valid and can be reasoned about, instead of repeatedly having to re-parse the version string.

The Content-Type header

The Content-Type header specification is defined in RFC 9110 sect. 8.3. Using ABNF the grammar for the header is

token          = 1*tchar

tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
                 / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
                 / DIGIT / ALPHA
                 ; any VCHAR, except delimiters

parameters      = *( OWS ";" OWS [ parameter ] )
parameter       = parameter-name "=" parameter-value
parameter-name  = token
parameter-value = ( token / quoted-string )
  
media-type = type "/" subtype parameters
type       = token
subtype    = tokenContent-Type = media-type

Content-Type = media-type

It also notes

The type and subtype tokens are case-insensitive.

and gives these examples

text/html;charset=utf-8
Text/HTML;Charset="utf-8"
text/html; charset="utf-8"
text/html;charset=UTF-8

RFC 8259 defines the application/json media type, and is explicit that this type does not have a charset parameter, writing:

Note: No “charset” parameter is defined for this registration. Adding one really has no effect on compliant recipients.

What to do?

The original questioner can now decide whether they want to validate the header as application/json, or parse the header to a more specific set of types.

Validation

From the definition of the Content-Type header and the application/json media type we can see a sensible strategy for validation would be:

  1. Remove everything from the header value after-and-including the first occurrence of ;
  2. Trim any whitespace from the start and end of the string
  3. Perform a case-insensitive comparison of the value with application/json

Step 1 handles malformed clients or servers that emit variations of application/json;charset=utf-8 (or any other charset). While technically not correct this is unlikely to impede interoperability.

Step 2 is also for malformed clients or servers. The specification does not allow spaces before the primary type (application), and there may be spaces between the subtype and the ; that was removed in step 1. Again, removing these should be harmless and improves interoperability.

Step 3 validates the header contains the correct value.

Parsing

Parsing the header is appropriate if the application is going to branch based on the type and subtype. For example, different handlers for application/json and application/activity+json.

To do that I would create specific Python types to represent the parameters and types that can appear in a Content-Type header, and have a function that parses the header and returns one of those types.

The types should include an explicit Unknown value to indicate an unhandled type, and can either raise errors or return an additional error type.

For example:

import re
from dataclasses import dataclass

# This is an example to demonstrate parsing a value to one of a
# specific set of types, distinct from simply validating the value
# matches expectations.


@dataclass
class BaseParameter:
    """
    Base class for all parameters.

    value: Value of the parameter as it appeared in the header.
    """
    value: str


@dataclass
class Charset(BaseParameter):
    """A 'charset=...' parameter."""
    pass


@dataclass
class UnknownParameter(BaseParameter):
    """
    An unknown parameter.
    
    name: Parsed name of the parameter
    value: Parsed value of the parameter
    original: Original parameter string as it appeared in the header.
    """
    name: str
    original: str


Parameter = Charset | UnknownParameter
"""Parameters that can occur in a content-type header."""


@dataclass
class ApplicationJson:
    """The application/json content type."""
    pass


@dataclass
class ApplicationActivityPlusJson:
    """
    The application/activity+json content type

    charset: Parsed value of the 'charset' parameter, if present.
    """
    charset: Charset | None


@dataclass
class UnknownContentType:
    """
    An unknown content type.

    type: Parsed name of the type
    parameters: Parsed list of parameters, if present.
    original: Original content type as it appeared in the header.
    """
    type: str
    parameters: list[Parameter]
    original: str


ContentType = ApplicationJson | ApplicationActivityPlusJson | UnknownContentType
"""Possible content type values."""


# THIS IS NOT A PRODUCTION QUALITY PARSER
def parse_content_type(val: str) -> ContentType:
    r = re.compile('^(?P<type>[^;]+)(?:;(?P<parameters>.*))?')
    m = r.search(val)
    if m is None:
        raise ValueError
    type = m.group('type')
    if type is not None:
        type = type.lower().strip()
    match type:
        case 'application/json':
            return ApplicationJson()
        case 'application/activity+json':
            parameters = parse_parameters(m.group('parameters'))
            charset = ([e for e in parameters if isinstance(e, Charset)][:1]
                       or [None])[0]
            return ApplicationActivityPlusJson(charset)
        case _:
            parameters = parse_parameters(m.group('parameters'))
            return UnknownContentType(type, parameters, val)


# THIS IS NOT A PRODUCTION QUALITY PARSER
def parse_parameters(val: str) -> list[Parameter]:
    if val is None:
        return []

    parameter_strs = val.split(';')
    r = []
    for parameter_str in parameter_strs:
        parameter_str = parameter_str.strip()
        (name, value) = re.split(r'\s*=\s*', parameter_str, maxsplit=1)
        match name.lower():
            case 'charset':
                r.append(Charset(value.lower()))
            case _:
                r.append(UnknownParameter(name, value, parameter_str))
    return r


if __name__ == "__main__":
    print(parse_content_type('application/json'))
    print(parse_content_type('application/JSON'))
    print(parse_content_type('  application/json  '))
    print(parse_content_type('application/json ; charset=utf-8'))
    print(parse_content_type('application/activity+json'))
    print(parse_content_type('application/activity+json;charset=us-ascii'))
    print(parse_content_type('made/up; charset=utf-8; some=parameter'))

    match parse_content_type('application/activity+json;charset=us-ascii'):
        case ApplicationJson():
            print('It was some form of application/json')
        case ApplicationActivityPlusJson(charset):
            print('It was some form of application/activity+json')
            if charset is not None:
                print(f'Charset was {charset.value}')
        case UnknownContentType(type, parameters, original):
            print(f'Content type "{original}" was not recognised')

Anything else?

Additional checks could be performed.

  • Instead of ignoring the charset parameter, parse the value and confirm it is utf-8. If it is not then signal the error in some fashion so the operator of the offending client/server can be informed. For example, receiving application/json;charset=us-ascii should be a red flag somewhere.

After recent events and looking at the existing open source Android Mastodon clients I decided there's space for one more, with a specific focus.

#Pachli (website, google play, f-droid, github, mastodon)

To quote from https://pachli.app/pachli/2023/09/06/hello-pachli.html:

Pachli is an application (now) and an association (aspirational).

Pachli-the-application is a best-in-class open source Android application for Mastodon and Mastodon-like servers. If you’re familiar with Tusky then you’ll love Pachli.

Pachli-the-association is intended to provide a first class organisation to manage the development of the application under the 7 cooperative principles:

  1. Voluntary and open membership
  2. Democratic member control
  3. Member economic participation
  4. Autonomy and independence
  5. Education, Training, and Information
  6. Cooperation among Cooperatives
  7. Concern for Community

The application is a rapidly iterating fork of the popular Tusky Mastodon client, branched from version 23.0 of that application.

Pachli includes many changes from Tusky 23.0, including:

New features

  • “Load more” no more. As you scroll through your feed new posts transparently load in the background
  • Your reading position in a timeline is always preserved
  • View trending posts and links from your server
  • Use more than five tabs
  • Choose from a range of accessible fonts
  • A new media player component, supporting more video formats
  • Mark lists as “exclusive”. Posts from accounts on exclusive lists only appear in the list, not your home timeline
  • Add bookmarked posts to their own tab
  • Hide “self-boosts” from your timeline

Bug fixes

  • No more missing posts; Tusky and other apps can miss posts from your timeline, Pachli always shows the full timeline
  • Images don’t “stick” when you zoom or swipe between them
  • Search text is not deleted when you move between search tabs
  • Show server error messages, making it easier to understand why an operation failed
  • Filters now work in notifications

UX changes

  • A visual refresh and new colour scheme
  • Determine the default theme (light or dark) from your Android settings, not hardcoded to “dark”
  • Move “Clear notifications” to a menu to make it more difficult to trigger accidentally
  • Extend the list of available languages

Download Pachli from Google Play and F-Droid.

Edit to add this set of links to the posts in the series


In Update #2 on “Stepping back” I alleged that some of the public statements by the Tusky project, prompted by Stepping back from the Tusky project, were lies.

It is my responsibility to present evidence for that. This is the last of a collection of posts that do that, or otherwise respond to the public statement by the Tusky team.

[!NOTE] See also Update #3 on “Stepping back” and Update #4 on “Stepping back”

You may be reasonably concerned that the following selectively quotes from the discussion. To allay those concerns the full discussion is archived at chat.md. You might want to read that first, or have it open in another window.

I have not addressed every statement made by the project in what follows. Blame Brandolini's law. Given the volume of trivially provable lies the project's contributors made in the statement they produced I leave it to you to decide how trustworthy the remainder of their comments are.

The “Conflict with Nik Clayton” section

Nik would have expected to find documentation of the decision to hire Z, but in this case would not have been able to do so because the hiring predates our current project documentation process. - Tusky Contributor response to Nik Clayton

This is a lie. I was not asking for documentation for decision making around Mike's hiring.

I was asking why the invoice showed a payment for work that was demonstrably not done.

Even if Mike's contract was for time, not specific results, the invoice should have reflected that. E.g., “X hours spent under contract Y”.

The three invoices actually said (in their entirety):

  • “First invoice submission to Tusky”, #129964
  • “Second invoice submission to Tusky”, #123650
  • “Work on 'bookmark tab' feature (#2368); Work on 'copy hashtags into reply' (#3013); Work on 'positional substitution format' (#3297)”, #125597

When he was shown the documentation of what Z had done and why Z was paid for it, he was unable or unwilling to drop the matter - Tusky Contributor response to Nik Clayton

This is a lie. I was not shown documentation of what Mike had done.

Had I been, that's still not relevant, because my concern was not “What other work has Mike been doing?” my concern was initially “Why does invoice #125597 list work that was demonstrably not done?”, followed rapidly by “And why are these other two invoices so generic?”, then followed by “Why is the response to what should be straightforward questions so hostile?”

There are three people who had admin rights to the Open Collective project and could respond; connyduck, tak, and maloki.

Of those three, the only person who responded was maloki, and the entirety of her response pertaining to the work was the following two sentences.

It's soma, and we had an agreement that he'd be paid for the first 3 months on an hourly basis with a max ceiling. Part of their work was also offloading me, and was supposed to be working on the FAQ, but you submitted your update of it while they were working on it.

If you can straight-facedly refer to that as “shown documentation of what Mike had done”, well, you're a better dissembler than I am.

Note also that the second sentence attempts to deflect blame on to me for apparently doing work that overlapped with Mike's.

Given that my questions were not answered it is not surprising that I was “unable or unwilling to drop the matter”, and I replied:

My PR that updated the FAQ (also my first project contribution) was submitted on Dec 17 2022, and merged on Dec 22 2022. There was no public indication before then that anyone else was working on it.

The three Mike Haynes invoices on OpenCollective have the following dates and descriptions (Mike Haynes – Open Collective):

  1. January 31, 2023 (“First invoice submission to Tusky”), 120964
  2. February 16, 2023 (“Invoice for first half of February”), 123650
  3. March 1, 2023 (“Work on 'bookmark tab' feature (#2368); Work on 'copy hashtags into reply' (#3013); Work on 'positional substitution format' (#3297)”), 125597

For the first two, what work was actually done? I can't find any PRs or issues (either open or closed) in the tuskyapp/tusky or tuskyapp/faq repositories that would match, and SomaRasu (Mike Haynes) · GitHub shows no apparent contributions.

I assume there is a straightforward explanation for this, I'd just like to know what it is.

But as and when there's a legal structure backing Tusky this will not fly. Invoice 3, in particular, looks fraudulent.

To be super clear — I'm 100% not saying it is fraudulent. This could be as simple as “Some work was intended to be done, it wasn't, other work was done instead, and the expense description was not updated”.

But we should be able to explain what that work was, and we should have processes in place so that this does not happen again.

I take a moment to note that some people have suggested that “I'm 100% not saying it is fraudulent” is a weasel-worded way of trying to suggest that something is, in fact, fraudulent.

With no other context, I could understand that point of view. However, this is not a context-free discussion. At this point I have been working on the project for approximately eight months, landed more than 150 PRs, replied to countless user questions from the Tusky account, and run the two most recent releases.

And the project's statement says they had “the intent of admin status being transitioned to him” and described me as the “primary code contributor”.

With that context it should be clear that raising any concerns like this are not an accusation of wrong doing, but an expression of concern that other people, without the relevant context, may look at the same information I did and assume fraud.

Maloki did not take it that way, responding:

Can You stop? Because let it be clear, this attitude would mean that all my contribution also isn't valid and shouldn't have been paid for because there wasn't any proof. This is an admin issue. The admins had agreements in place with people involved.

This is the first suggestion in the discussion that any of Maloki's contributions are questionable, and she raised it, not me.

I was not the only person to think like this. Charles (charlag) wrote in response to the above:

mal with you I think it was quite different. I don't know anything about this (which is probably my fault) and to me it also looks very odd.

if in your case it's a regular payment in this case it does really look like payment for work that wasn't done. I think big point of OC is transparency and this does not look good for us.

should it have been labelled d