#ktistec 72 hashtags

I just released v2.0.0-8 of ktistec. The most impactful changes are:

  1. No more dependencies on externally hosted assets (and fewer dependencies, overall)
  2. Basic support for timeline filters (no shares and no replies).
  3. Support for content filtering by keyword.

Volume has dropped off in my timeline, for the most part, now that the surge of people who signed up for Mastodon accounts a couple months ago have gone back to posting on Twitter, or have stopped posted about the transition, or whatever. But fine grained control is nice, and filtering allows me to tune my experience—better late than never!

Read the changelog for all of the details.


i released 2.0.0-7 just in time for the new year.  it includes contributions from @relistan and @rahul, the introduction of CI (the build is successful), and bug fixes.

i am slowly working my way toward more flexibility for reading and managing federated content.

#ktistec (as always, it's pronounced "tiz-tek")

getting starting on my new year's 🥂 resolutions early. added a changelog to ktistec.

the changelog covers changes back to v1.0.0, which was released about this time last year!


i finally set up ci for ktistec. surprisingly, it only uncovered one mysterious build issue...


(i should probably just add support for mastodon style polls to ktistec...)

i have an informal poll for ktistec users. should we require (and use features from) the most recent versions of sqlite? how recent is too recent? if you're running ktistec, i'd love your point of view.

some background... sqlite is the most significant dependency in ktistec. to minimize problems for potential users, i intentionally stuck to features found in "older" versions of sqlite. as i write this, the current version of sqlite is 3.40.0.  ktistec only depends on 3.11.0, which was released in 2016. that's very conservative.


Today's release of code fixes things that have been annoying me for a while: 

  • Commits c01e797 to b21a97a ensure that bulk assignment raises an error when the type of an argument value does not match that of the corresponding property being assigned to. In the past, attempts were silently ignored. As you'd expect, adding the check and raising the error was easy—cleaning up all the places I'd carelessly passed in nil and other garbage was not. Lesson learned? We'll see...
  • While I'm in there, commits 1ac498e to 3d45ece ensure that bulk assignment raises an error when attempting to assign a property defined only by a getter (which is, effectively, a read-only property). Previously, this code wouldn't even compile, thereby unintentionally coupling database persistence and bulk assignability.
  • Finally, commits 5c2ec70 to 99dca65 clean up a few small defects in presentation: wide blocks of code no longer blow out the width of the parent container, image attachments present at ratios closer to what Mastodon uses (the presumption being that's what people optimize for if they optimize for anything) (this should also fix issue #53), and figure captions get a little breathing room. I'm no good at CSS, so this kind of thing takes me forever.


attachment showing profile metadata from both a mastodon site and a ktistec site

i built @relistan 's branch this morning and tried out ktistec support for mastodon profile metadata. the attachment shows profile metadata pulled from the ruby.social mastodon instance, as well as @relistan 's own personal ktistec instance. this is something i've wanted for a long time!

a shoutout is due both these two (the owners of the two profiles shown in the attachment): @alexanderadam has been posting encouragement about ktistec all year long, and maybe before—an intangible that's immensely valuable when you're banging away on open source software—and @relistan is the first person besides me to contribute major feature functionality to the project—which takes a huge leap of faith.



i added some in-process data collection to ktistec in order to better understand how it uses memory.

attachment 1: chart of total requested, heap, and free

the chart shows the accumulated total requested memory (blue) over time. as expected, it grows monotonically and almost linearly. in theory, i guess, if i posted something engaging, you'd see the effect of the engagement  (likes, shares, follows, etc. etc. etc.) on memory usage. in any case, the heap (red) remains flat.

attachment 2: table of total requested, heap, and free

i think it would be great to have this chart on the metrics page. when time permits, i'll add it. in the meantime, if you're running a (very) recent build, you're collecting data.


i'm currently working on a few ktistec enhancements in parallel.

  • performance improvements to rule evaluation. performance isn't a problem during regular inbox/outbox processing imo, but large batch operations take too long and use too much memory.
  • dependency minimization (and removal). for expediency, a few dependencies are pulled from cdns instead of being served locally. since "minimal dependencies" is a feature i'm pushing, i should clean up where i can.
  • interoperability enhancements. the fediverse continues to evolve/innovate. it looks like another pass of interoperability testing might be warranted.

there are ~13 instances currently running that i'm aware of, so there's also a solid stream of bugs/enhancements coming in 😉 thanks everyone!


I've finally fixed a mysterious bug that was reported while I was traveling. I thought it would be interesting to share the investigation and resolution. Plus, it's an excuse to do some writing...

Rules-based logic has been part of ktistec for a while, and the rules engine has been running successfully in production for me and others, so I was surprised to learn that the recent introduction of a new rule resulted in spurious notifications. The rule in question is supposed to create a notification when someone replies to one of your posts. Instead, the rule was creating a notification when anyone replied to anything.

rule "create/reply"
  condition activity, IsAddressedTo, actor
  condition CreateActivity, activity, object: object
  condition Object, object, in_reply_to: other
  condition Object, other, attributed_to: actor
  none Notification, owner: actor, activity: activity
  assert Notification, owner: actor, activity: activity

I had unit tests for the rule's logic, and the logic seemed correct when I visually inspected the rule again. To top it off, I wasn't able to find a set of steps that reproduced the problem locally.

For various poor reasons, I hadn't tried the rule in production myself. With no other obvious path forward, I deployed it and waited. Sure enough, the bug surfaced—along with a stack trace helpfully correlated with every occurrence of the bug. Jackpot—or so I thought!

Exception: relationship: already exists (Ktistec::Model::Invalid)
  from /workspace/src/framework/model.cr:650:9 in 'save'
  from /workspace/src/framework/model.cr:649:7 in 'save'
  from /workspace/src/rules/content_rules.cr:49:3 in 'assert'
  from /workspace/src/utils/compiler.cr:118:5 in 'assert'
  from /workspace/src/utils/compiler.cr:39:19 in '->'
  from /workspace/lib/school/src/school/rule/rule.cr:38:23 in 'call'
  from /workspace/lib/school/src/school/domain/domain.cr:158:9 in 'run'
  from /workspace/src/rules/content_rules.cr:89:5 in 'run'
  from /workspace/src/controllers/inboxes.cr:248:5 in '->'

The stack trace was curious for two reasons. The error occurred when creating (asserting) the notification because a notification already existed, which 1) shouldn't be possible because the assertion is preceded by a guard condition that ensures that the notification does not exist! And of course, 2) the notifications were spurious—rule evaluation shouldn't have resulted in a notification in the first place...! Luckily for me, it soon got even weirder.

It's possible to trace rule evaluation. Turning tracing on revealed a surprising mystery: 3) thousands of successful activations of the "create/reply" rule for any given reply. Thousands! That did explain something, though. The first successful activation created the spurious notification. The second activation raised the error (because the notification had just been created). When evaluating rules, all matches are first found, and then all actions are taken, therefore, in this case, the guard condition couldn't have had any effect. The error also terminated any further action processing, so there was only one stack trace.

So, I swapped one curiosity for a mystery—but why were there thousands of matches for that rule? There should have been only one (really, none).

A trace prints information about conditions and the facts that match, along with information about the values that are bound to variables in the process.

Rule create/reply
  Condition School::BinaryPattern(ContentRules::IsAddressedTo, School::Expression, School::Expression)
    Match ContentRules::IsAddressedTo, bindings: activity=#<ActivityPub::Activity::Create iri=...> actor=#<ActivityPub::Actor::Person iri=...> []
  Condition ContentRules::CreateActivity
    Match #<ActivityPub::Activity::Create iri=...>, bindings: object=#<ActivityPub::Object::Note iri=...> [activity=#<ActivityPub::Activity::Create iri=...> actor=#<ActivityPub::Actor::Person iri=...>]
  Condition ContentRules::Object
    Match #<ActivityPub::Object::Note iri=...>, bindings: [activity=#<ActivityPub::Activity::Create iri=...> actor=#<ActivityPub::Actor::Person iri=...> object=#<ActivityPub::Object::Note iri=...>]
  Condition ContentRules::Object
    Match #<ActivityPub::Object::Note iri=...>, bindings: other=#<ActivityPub::Object::Note iri=...> [activity=#<ActivityPub::Activity::Create iri=...> actor=#<ActivityPub::Actor::Person iri=...> object=#<ActivityPub::Object::Note iri=...>]

Hmmm, that third condition... Nothing is bound to the variable other, as expected, yet the condition is treated as having successfully matched a fact. That's obviously incorrect—other should be the post being replied to. Or the condition should fail and rule evaluation terminate. Instead, since it is not bound but evaluation continues, other is free to be bound as necessary to satisfy the fourth condition. Which is exactly what happened.

The fourth condition matches posts that are attributed to me. Unless this condition is otherwise constrained—say, by a variable bound in the previous condition—there are going to be thousands of matches. Sokath, his eyes opened!

So, here's the fix. It's in the code that binds variables to values. in_reply_to is an association on a post (object) that links it to the post (object) it's a reply to. This code is inside a block that processes every potential match.

  {% for association in associations %}
    if @options.has_key?({{association.id.stringify}})
      if (target = @options[{{association.id.stringify}}]) && (name = target.name?) && !temporary.has_key?(name)
-       if (value = model.{{association.id}}?(include_deleted: true, include_undone: true))
-         temporary[name] = value
-       end
+       next unless (value = model.{{association.id}}?(include_deleted: true, include_undone: true))
+       temporary[name] = value
  {% end %}

The post that's being replied to isn't always cached locally. When it's not, the association returns nil and nothing is bound. That's okay. But previously, the match was also considered to be successful! The solution... treat it as a failure and proceed to the next potential match.

#ktistec #troubleshooting