All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.23.0 - 2026-09-07
-
A record in the atmosphere says who wrote it. These live in their authors' own repositories, which is the whole point of the tab, and a card that never named one read as though Chive had written it. Each now carries the account's avatar, display name and handle on its own line. The handle, not the DID: the DID identifies an account without naming it.
-
Semble collections and their membership are indexed. Semble draws a card inside a collection, which is where a reader actually sees it, and
network.cosmik.collectionLinkis the record that puts it there. Chive had never read that record: it indexedcollectionLinkRemoval, the tombstone an owner writes to remove a collaborator's link, and so watched cards being taken out of collections it had never seen them put into. Both the collection and the link are now indexed, and a Semble card on an eprint names the collection holding it and links there. -
Semble cards link to Semble. A card's address on Semble is
/profile/{did}/cards/{rkey}, which today renders "Card page -- coming soon!"; it is the canonical address and will start working when Semble builds the page. A connection has no address at all --/connections/{rkey}is a genuine "Page not found" -- so it is offered none. Both were established in a browser rather than by reading server HTML: Semble renders on the client, so its server response is the same shell whatever the route, and an earlier reading of that shell had concluded there was no card page. -
A connection names the paper at its other end. A Cosmik connection is an edge between two entities; Chive stored the note and the relation and dropped both endpoints, so a card said what its author thought about a relationship without ever naming the other half of it. Where that half is an eprint Chive holds, it is shown by title and linked; where it is a DOI or a catalogue page, by its address. The title is resolved at read time rather than stored, so a retitled paper is named correctly everywhere without reindexing.
-
Co-citation and bibliographic coupling reach related work. Both were implemented and neither was switched on.
-
Margin annotations link back to Margin. Its permalink addresses a note by its author's handle and answers "Not found" to the DID form, which is all an AT-URI carries, so the link could not be built. Backlink listings now resolve source handles through the existing batched, cached profile hydrator.
-
A repository on the Code tab is described as fully as Chive can describe it. A declared code link and a fetched integration for the same repository were two separate renderings of one thing; they are joined on the normalised URL and drawn as the richer card.
-
The atmosphere counts what it draws. The panel's corner badge came from the counts endpoint while the list came from the rows, and the two are maintained separately, so a panel could show one number directly above a list of a different length. The tab keeps the total; the panel's own filter row is the count. Its loading skeleton also said "Backlinks", so the section renamed itself as it finished loading.
-
Each service is drawn with its own mark. Leaflet, Semble, Margin, standard.site and Chive publish vectors; where a service publishes none, the card keeps a generic glyph and the service's name, because a generic glyph identifies nothing on its own.
-
A typed field was rendered as the opening words of a title. A Margin motivation and a Cosmik relation were prefixed onto the text a reader sees, so a card read "commenting: The gradable adjective case is..." -- structured data presented as the start of a sentence. They are drawn as chips, and in prose casing, since the lexicons disagree about how to spell an enum: Margin writes
commenting, Cosmik writesRELATED, a Chive relation slug isbuilds-on, and rendered as written three chips in one list were in three different cases. -
A title and a description were joined with a colon. A Leaflet document and a standard.site document each carry both, and run together they read as one run-on sentence.
-
A connection naming a paper by its Chive web address was indexed under a key nothing could look up. That is how Semble writes an endpoint -- the address its author was looking at -- so the row existed, the paper never showed it, and
backlink.listwould not accept the URL as a target either. Endpoints are resolved to eprint AT-URIs before being recorded. -
Chive's own standard.site document is no longer listed as a reference to the paper. That document is the eprint, published outward; it is not something on the network pointing at it.
-
A collection owner's link removal is applied, not merely announced. The plugin emitted an event with nothing subscribed to it, so a card an owner removed stayed indexed as a member of the collection, and the tombstone table the migration created for it stayed empty. The tombstone is kept too, because deleting it is how an owner undoes a removal and a deletion event carries only the tombstone's own URI.
-
The record URI is gone from atmosphere cards, where a "View record" link already goes to the same place.
0.22.2 - 2026-09-06
-
Citation arrows ran through the papers and overshot them. Every edge was routed between React Flow handles, and a handle is fixed to one side of its node -- with the target handle at the top, an edge whose cited paper sat above its citing one had to travel up through that paper's body to reach its top edge, crossing the pill and leaving the arrowhead inside it. Edges now compute their own endpoints from the two node outlines, so each meets whichever point faces the other. Nothing overshoots, because the endpoint is the boundary, and nothing enters a node, because the segment stops there. The
zIndexin 0.22.1 had treated a symptom of this rather than its cause. -
The line showed through the arrowhead. The fade on an edge was element
opacity, which applies to the path and the marker alike, so where the line ran under the arrowhead the two composited and the line read as a darker streak through it. The fade is carried in the stroke colour now, and the arrowhead takes exactly that colour.
- A paper more than one citation away is drawn as a dot, with no label. At fifty papers the names were the clutter, and at five hundred they would be unreadable; the ones worth reading are those beside the paper in hand or beside whatever the reader has clicked. The rest are the shape of the field, which a dot conveys better than a name nobody is reading. Hovering still gives any paper its full bibliography entry, and a dot grows into a labelled pill the moment it attaches to the focus or the selection.
0.22.1 - 2026-09-06
- Citation arrows were drawn on top of the papers. Lit edges carried an explicit
zIndex, which lifts an edge out of the layer React Flow draws beneath the nodes, so every highlighted arrow crossed the pills it ran between and cut through their labels. They keep their place above the faded background edges by ordering within that layer instead, which is where the distinction belonged.
0.22.0 - 2026-09-05
-
A citation network that is a network. The graph on an eprint's network page fetched one paper's citing and cited lists, stacked them in two columns, and had no way to draw anything else — so it could only ever show a star. It could not show that two of a paper's citers also cite each other, which is the one thing a network picture exists for.
pub.chive.discovery.getCitationNetworkreads the citation graph as a graph. The whole Chive-to-Chive network arrives in one request, laid out by simulation so that proximity means something, and a reader can zoom out from the paper they came for to see where it sits among the rest. The focused paper's own edges are read before anyone else's, so a network cut short at the limit still contains the paper it was asked about — the one guarantee a client cannot recover for itself.Colour carries two things at once. What a paper is to the paper being read against: mustard for that paper, red for the papers it cites, blue for the papers citing it. And which paper that was: the paper a reader arrived on stays solid however far they wander, while a paper they click lights its own neighbourhood in the same three hues a shade paler. Clicking around the graph never repaints where they started, which is what makes it explorable rather than disorienting.
Hovering a node gives the bibliography entry — every author named, year, venue, DOI — because the nodes themselves carry only a surname and a year. The old ones carried a truncated title and two badges, which is why four fitted on a screen and four hundred could not.
-
The citation graph had no arrows, and no lines either. Every edge was styled
stroke: var(--green-500), a variable this application has never defined, so the browser drew nothing between the boxes at all: no line, no arrowhead, no direction. A citation without direction is not a citation — "A cites B" and "B cites A" were the same absent line. The network now draws from a fixed palette a stylesheet cannot silently withdraw, with an arrowhead on every edge in the edge's own colour, and the styling has moved out of the component into pure functions that a test can hold. -
Two of the links out of the atmosphere panel went nowhere. Carried over from the same pass: a
network.cosmik.cardwas linked as though it were a Cosmik collection, which 404s, and every Leaflet reference was linked as a document because the indexing plugin files comments under the same source type. -
An eprint reference could not be named in a bibliography.
eprintRefcarried title, authors, year and venue but not the DOI, so a hover card built from it was a citation with the identifier missing.
0.21.0 - 2026-09-05
- An Atmosphere tab, holding everything the network has said about a paper. References from elsewhere on the network used to sit at the bottom of the Citations tab, under a heading called "Backlinks", as a stack of collapsed accordions that fetched nothing until a reader thought to open one. The section names came from the counts endpoint, which buckets five ways — so a talk, a standard.site document, a Margin annotation and a Cosmik connection all hid behind a heading reading "Other Sources" that named none of them. They now have a tab of their own, loaded at once, filtered by the application that published each record, and the tab says how many there are before it is opened. Where a paper has none, it says so rather than leaving a blank tab, because an empty panel cannot be told from one that failed to load.
-
Every link on an eprint page renders through one card. Code, data, materials, external identifiers, linked resources and atmosphere references each had their own layout: a tinted icon and a platform badge on one tab, a flat row with a monospaced value on another, a small grey icon and a line of context on a third. None of the differences carried meaning — a reader learns nothing from a DOI being styled unlike a repository — and the richest of them, the GitHub card, was the one a reader saw least often. That card is now the shape all of them take, and each fills it with whatever detail its own source can give.
The detail itself is the point. A card headed "GitHub" now reads
chive-pub/chivebeneath it, and one headed "arXiv" leads with the identifier: the address was in the record all along and simply was not shown. Zenodo reports its deposit type, access, version, views and downloads; Software Heritage says what is archived and when it was last seen; an atmosphere reference says which application published it, what kind of record it is, when it appeared, and what it said.
-
Two link destinations on the atmosphere panel were wrong, and most were missing. A
network.cosmik.cardwas linked tocosmik.network/collection/{did}/{rkey}, which 404s — a card is not a collection. Every Leaflet reference was linked as a document, comments included, because the plugin that indexes them files both under one source type. And for a talk, a standard.site document or a Margin annotation, no link was offered at all.Links are now built from the collection NSID carried in the record's own URI, which cannot disagree with the record, rather than from Chive's coarser classification. An application's own address is offered only where the route was checked against a live record — Smoke Signal and Bluesky, verified; Cosmik's dropped rather than shipped wrong. Alongside it every reference now carries a link to the record itself in a public record browser, which resolves any AT-URI by reading it from the repository that holds it. That one works for every source type, including the ones no application on the network renders yet.
-
A repository the author named but gave no address for vanished, while the tab still counted it. A Code tab reading "2" above one card reads as a fault in Chive rather than as an incomplete record. The card renders and says the record carries no address.
-
The supplementary materials count ignored the datasets it was displaying. It counted uploaded files only, so a paper whose auxiliary material is a dataset linked on Layers showed a badge reading "0" above the card for it.
0.20.2 - 2026-09-05
-
A paper shared from Chive carried the generic Chive card. The share dialog uploads the OpenGraph image as a post thumbnail rather than letting Bluesky refetch the page, and the url it fetched was hardcoded to
?type=default— so the same link pasted straight into Bluesky showed the paper's own card while sharing it from the paper's page did not. Both now build the url through one helper, which is also what stops them drifting apart again. -
A record referencing several papers backlinked only one of them.
backlinkswas unique onsource_urialone, while the plugin that writes them emits one row per referenced eprint — so each write overwrote the last and every paper but the final one silently lost its backlink. A Cosmik connection names two eprints by definition and showed on only one; an essay citing three showed on one. The identity of a backlink is the pair, and it is now keyed that way.
0.20.1 - 2026-09-04
-
A repository referenced by AT-URI was labelled "Layers" whatever it was. Any
at://reference on the Code tab took the Layers icon and name, so a Tangled repository — the one case the feature exists for — displayed as a Layers dataset. Layers datasets are now identified by the kind of record their URI names rather than by being an AT-URI at all, which also keeps the entries written before a platform was recorded rendering correctly: they carryplatform: "other"and were relying on that same forcing. -
The governance admin dashboard was unreachable for every administrator.
getEditorStatusreturned a 500, and the frontend reads the caller's role from it — a failed request leaves the role undefined, which renders as "Access Restricted". The cause was one column:governance_rolesis LEFT JOINed, sogranted_bycomes back asnullfor anyone never granted a role, and the lexicon declaresroleGrantedByan optional string. Passingnullthrough failed output validation.displayNamebeside it already had?? undefinedandroleGrantedAtwas reached through optional chaining; this one had neither. Since holding no stored role is the default for every user, platform administrators included, it failed for everyone. -
New methods were callable by nobody. The feed, the two notification queries, and starting a claim from an external source were absent from every OAuth permission set, so a PDS refused to mint a service-auth token for them and the browser saw a 401. Readers must re-authorise to pick the grants up. The external-claim method had been missing since before this release.
-
The feed filters were named after the query rather than the reader. Seven checkboxes distinguishing reviews of an author's papers from reviews they write, where five suffice: Papers, Reviews, Endorsements, Annotations, Citations. Each group covers the event types it implies.
-
A two-line entry in the Discover menu pushed its row out of line with the rest.
- An eprint page names the publication its document belongs to, and an author page names the publication it hosts. Groundwork rather than a working feature: an enhanced Bluesky link card needs both of those links and a
/.well-known/site.standard.publicationendpoint on the publication's domain, which Chive does not serve. Until it does, no subscribe control appears on a link card. The endpoint returns one AT-URI per domain, so a publication per author under a singlechive.pubcannot be verified as it stands — that is a design decision still to be made, not an oversight in these tags.
0.20.0 - 2026-09-04
-
One feed for everything you follow. Following an author creates a collection, so "everything I follow" and "every collection I hold" are the same question asked at different widths.
/feedasks it, with a scope control for the width: only the collections created by following someone, every collection you own, collections other people own that you follow, or all of it. The activity checkboxes from a subscription apply here too, so a reader who wants new papers and nothing else can say so once.Deduplication happens in the database rather than the browser. A reader who follows the same author from three collections gets three person nodes and so three copies of every event; the feed collapses them into one row that names all three collections. Merging pages in the client could not do that — it cannot collapse two rows that are the same event, and a cursor over merged pages is not a cursor over any one of them. The engine that does this is the collection feed itself, now given a set of collections instead of one, so there is no second implementation to drift.
-
An author is told when someone follows them or collects one of their papers. Two queries computed from the index rather than a stored notifications table, following the pattern the existing review and endorsement notifications already use, and shown on the notifications dashboard beside them. Neither takes a DID from its parameters: the answer is about whoever is signed in, so reading someone else's is not expressible.
-
The standard.site social graph indexed nothing, ever. Subscriptions, publications and recommendations were all read off the firehose and all silently discarded:
standard_site_subscriptions,standard_site_publicationsandstandard_site_recommendationswere empty in production, and a publication written while the indexer was live and fifteen seconds behind the firehose never appeared.loadBuiltinPlugin(plugin, services)hands those services to the context factory asconfig, so a plugin finds them atcontext.config.x. This one readcontext.x, gotundefined, and returned at the guard meant for a genuinely absent service — before subscribing to a single event. It loaded, reported healthy, and did nothing. The mistake typechecked because the old code asserted the property onto the context with a cast rather than reading where it lives, which is the reason the fix comes with a test that loads the plugin the way the loader really loads it and asserts it subscribes.Until now this made
subscriberCountpermanently zero and meant thesite.standard.graph.subscriptionrecord was never written when following an author, since that write is gated on the author holding a publication the index could see. -
Importing papers failed with a 500.
getSuggestionsdied ons.toLowerCase is not a functionwhile scoring external results. The OpenReview plugin declaredcontent.authors.valueasstring[]; the API does not honour that, and a search for a common name returns collaboration papers whose author list is thousands of{fullname, username}objects. Author entries are now normalised where they enter, the type no longer claims something untrue, and the scoring path guards the other external sources — one malformed author costs that paper its score rather than blanking the list. The ten seconds before the failure was the plugin search budget, not the cause. -
An author could not see they had followers. The count was computed and rendered nowhere, so the follow control said nothing about who was on the other side.
-
An eprint's link card carried no subscribe control. A standard.site consumer finds the document by fetching the page and reading
at:canonicalfrom its head; Chive emitted only alink rel="alternate"naming the eprint record, which is not a document and resolves to nothing. The page now names itssite.standard.document, and since the document names the author's publication, that is all a consumer needs to reach both. Papers submitted without cross-platform discovery have no document, and the remaining head tags are emitted regardless. -
Three event types were invisible in the feed UI. Reviews, endorsements, and annotations on a tracked author's papers arrived from the server but fell through every icon, label, and attribution switch to the generic "Activity". They now read as what they are, attributed as "On a paper by".
0.19.0 - 2026-09-04
-
Following an author is a collection. Following someone creates a collection in the follower's own repository holding exactly one item — that person — and the activity feed collections already have does the rest. Nothing about it is a separate mechanism: the collection appears in their library under the author's name, and it can be opened, renamed, extended with a second author, or deleted like any other. The control says so before it writes anything, because a collection that appeared in someone's library unannounced would read as a bug rather than as the feature.
This also lifts a limit the earlier design carried. Following used to require the author to hold a standard.site publication, which meant the control was hidden for the great majority of authors. A collection needs nothing from the person being followed, so anyone can be followed now.
-
A follower chooses which activity reaches them. Seven kinds, each a checkbox: their new papers, reviews and endorsements and annotations of their papers, reviews they write, endorsements they give, and papers that cite them. New papers and the responses to them are on by default; a feed that turns everything on by default is a feed people mute. The choice is stored on the collection record in the follower's own repository, not in Chive — a preference the AppView owned would be a preference that could not be rebuilt from the firehose.
-
A publication, which is what a reader subscribes to across the ecosystem. It carries the name shown on link cards and lives in the author's own repository, so Chive can neither create nor rename one on their behalf — hence a settings panel rather than a stored setting. Its url is not editable: it is what a publication is matched on and what existing subscriptions were written against, so changing it would orphan every subscriber. When an author holds one, following them writes a
site.standard.graph.subscriptionrecord alongside the collection, so the follow is legible to the wider ecosystem. That write is best effort; the collection is the subscription, and a standard.site that cannot be reached must not read as a failed follow. -
A way to publish an eprint's standard.site document after the fact. The submission wizard offers to write one and an author may decline; every paper submitted before that offer existed has none. Both leave a paper readers outside Chive cannot find, with resubmission the only way back. Both eprint editors now carry the control, which also offers to re-point a document that names a bare url — the form the schema reserves for loose documents, and one no reader can subscribe to the author from.
-
A collection feed can be asked for only certain kinds of event.
pub.chive.collection.getFeedtakes atypesparameter. Branches that were not asked for are not emitted at all rather than filtered after the fact, so each branch a caller did ask for keeps its full row budget instead of spending it on rows that would be discarded. This is what a subscription's activity choice is built on, and it is available to every collection feed. -
A collection holding a person now surfaces what other people do with that person's work. The feed's review, endorsement, and annotation branches fired only for eprints held in the collection directly, so a collection tracking an author showed their output and nothing anyone said about it. Three branches now reach through authorship. The author's own activity is excluded from them, since it already arrives as their review or their endorsement, and the same record emitted under two type names is a record the feed's deduplication does not collapse.
-
An eprint's standard.site document names its author's publication. It named the site root, which the schema keeps for loose documents. An eprint is not loose: naming the publication is what puts a subscribe affordance on a link card anywhere in the ecosystem. The publication is keyed on the author's DID rather than on a handle or a display name, either of which they can change — and either of which would mint a second publication and split its subscribers.
-
"Open the citation network" led to an error page. There was no citation route. The link resolved against the eprint page's catch-all, which read the trailing
citationssegment as part of the AT-URI and asked the API to load an eprint whose uri ended in/citations. The view now has a route of its own, and the graph it renders had until now been built but never reachable from anywhere. -
Citations named one author and cut the rest. A citation now names all of its authors when there are one or two and the first followed by "et al." when there are more, rather than only ever the first. Surnames survive inverted forms, particles, and suffixes, and a name that is a single word is printed whole instead of being presented as a surname it is not. The em-dash between the byline and the title is gone and the title is set in italics.
-
The site did not fit a phone. The published banner squeezed its label and its link into one row until neither was readable; it stacks now. Chips broke inside their own pill rather than wrapping between pills, and long labels truncate instead of forcing the row wider. The eprint and author page toolbars wrap rather than crushing the name beside them, dialogs and wide popovers are capped to the viewport, and the search, field, collection and dashboard pages no longer outgrow a narrow screen. Row controls that only appeared on hover are now visible on touch screens, where nothing ever hovers.
0.18.3 - 2026-09-04
-
The citation network listed papers by AT-URI. Two surfaces draw citations — the network graph and the summary list on an eprint's Network tab — and each had its own rendering. The graph was taught to name papers; the list kept printing
at://did:plc:.../pub.chive.eprint.submission/...at every row. The labeller is now one module both import, since a copy in each is how one comes to read correctly while the other does not. -
A paper announced two different reference counts. The section headers counted the rows fetched under a page limit while the panel header counted the rows that exist, so the same paper read "11 references" and "References (5)" one line apart.
-
Only the first page of citations was shown. A network listing five of eleven references omits exactly the ones a reader went looking for. Every page is now collected — the API caps a page at 100, so the cursor is followed until it runs out, with a guard against a cursor that never advances — and all of them are rendered.
0.18.2 - 2026-09-03
-
Citation nodes showed 1572 where they meant 2016.
published_version.publishedAtis stored as epoch milliseconds in a text field — every record that has it, and none as an ISO date — and the year was read as the first four characters of that string, giving the leading digits of the timestamp. Close enough to a year to pass review and render on a node. The unit test supplied a row with the year already extracted, so the query itself was never exercised; it now asserts the SQL. -
The share card for the site itself had no image. The metadata named
/og; the generator lives at/api/og. Nothing fails visibly when an unfurl asks for an image that is not there — the card simply renders without one — and eprint pages looked correct throughout, because they build their path from the generator rather than from that literal.
-
A backlink from a pasted link pointed at nothing. A reference to a paper arrives written the way its source writes it: someone citing a paper in an essay pastes
https://chive.pub/eprints/..., a Cosmik card is a link card, a Margin annotation targets a page. Only a machine writes the AT-URI. The test for "is this an eprint" was a substring check that a percent-encoded address also passes, so the address was accepted and then recorded verbatim — filing the backlink under a string no eprint can be looked up by. The row was written, nothing raised an error, and it rendered nowhere.Every reference is now resolved to the eprint's AT-URI before it is recorded, in the Leaflet, Cosmik, Margin and calendar plugins. The resolver is one implementation shared by all of them rather than a copy each, since a copy per plugin is how one comes to accept a form the others reject. Two tests asserted the old behaviour outright and now assert the AT-URI.
- A dataset can be referenced by its record, not by a URL it does not have. A dataset published on Layers has no web address — its
pub.layers.catalog.collectionrecord is the durable identifier — so its at-uri was going intorepositories.data[].url, a field declaredformat: uriand described "Repository URL". It rendered correctly, but the field means something else, and the code-repository shape has had arecordUrifor exactly this since it was written. The data shape now has one too. Both are read, the dedicated field first, so records written under the old placement keep working.
-
Linking a Layers dataset never worked, in either direction. The write asked the author's PDS to validate
pub.layers.eprint.dataLink, and_lexicon.layers.pubpublishes no TXT record at all, so the NSID cannot be resolved and the PDS rejected the record outright — the link was silently never made, which is why no repository holds one. The read asked a Layers AppView that is still in development and answers nothing, so the panel was empty regardless.Neither was necessary. These records are written by the submitting author into that author's own repository: the eprint's AT-URI names the author, the DID document names their PDS, and the records are one
listRecordsaway. Chive reads them from there, and asks no AppView. The cost is stated rather than hidden — only links the eprint's own author wrote are found, and a third party linking their dataset to someone else's paper stays invisible until Layers publishes an index that can be asked the reverse question.
0.18.1 - 2026-09-03
- A citation network of anonymous boxes. The graph stores only URIs on its nodes, so an edge read back from it carried nothing a reader could recognise, and every node rendered as "Citing paper 1", "Citing paper 2" — a network that is technically correct and tells a reader nothing.
pub.chive.discovery.getCitationsnow returns the papers at both ends of the edges it reports, named the way a paper is cited: first author, year, title, and the venue when known. They come back as a lookup rather than as metadata repeated on each edge, since one paper commonly sits at the end of many of them, and they are resolved in a single query rather than one per edge.
-
A quarter of the corpus had a PDF and no references at all. Citation extraction runs once, when an eprint is indexed, and degrades gracefully when GROBID cannot be reached — correctly, since one unreachable service must not fail an indexing run. But the degradation left no trace, and nothing retried: an eprint whose extraction failed was indistinguishable from one whose references had been read and found to be none, because both have no rows. On production this was 18 of 66 eprints, including papers with 141 references apiece, and the only symptom was a citation network smaller than it should be.
Every attempt is now recorded, so a failure is visible as a failure. The deploy retries the eprints never successfully processed, selecting on that record rather than on the absence of citations — a paper genuinely without matchable references is not put through GROBID again on every deploy. Backfilling production took extracted citations from 2,468 to 3,816 and cross-citation matches from 131 to 173.
-
A matched citation whose edge was never written stayed invisible forever. The re-match considers only citations with no match yet, which is right for matching and wrong for edges: every citation matched while the graph had no eprint nodes to attach to was left settled, matched, and unconnected, and nothing revisited it. On production that was all 131 of them. The deploy now writes the edges implied by matched citations before re-matching the rest; both the node and edge writes are merges, so it costs a no-op once the graph is current.
-
Staging never ran the citation steps. They existed only in the production deploy, so staging's graph stayed empty whatever the code did and the first place to find that out was production. Staging now runs the same labelling and re-match.
0.18.0 - 2026-09-03
- A citation graph with nodes in it. Every eprint reached PostgreSQL and Elasticsearch, while the graph gained a node for one only if a reader happened to interact with it — so there were no eprint nodes, and no
CITESedges could exist. Citation edges match their endpoints rather than creating them, deliberately: an edge must never assert a paper Chive does not hold. But nothing was supplying the nodes that guard depends on, and aMATCHthat matches nothing writes nothing and raises nothing, so every edge was dropped in silence while the matches accumulated in PostgreSQL. Callers holding the eprint index now supply the nodes, re-checking each URI against that index rather than trusting it, and a script labels the label-less nodes two other writers had already left in the graph.
-
Citation matching used two of the fields GROBID gives it and ignored the rest. A reference was resolved by DOI and then by exact title, and nothing else — while the authors, year, venue, volume and pages extracted alongside them sat unread in the table, and the arXiv id was never extracted at all, so its column had been null for every citation ever stored. The matcher now works strongest identifier first: DOI, arXiv id, exact title, then a near title accepted only when an author surname or the year corroborates it. A near match with nothing to corroborate it is left unmatched, because a wrong edge in a citation graph is worse than a missing one.
Three things were quietly defeating the exact comparison. Titles arrive with the citation's own furniture attached —
2023a.,press.— which is now stripped. DOIs arrive as URLs, with a sentence's punctuation attached, or as the tail.org/10.…of a URL whose front was lost, which is now normalised. And the SQL normalisation collapsed no whitespace where the TypeScript collapsed it, so the two disagreed about what "exact" meant; they are now written once and shared. On the production corpus the stripping alone takes exact title matches from 26 to 28 before the near-title pass runs at all.The re-match carried its own copy of the old DOI-then-title logic, so none of this would have reached the pass that actually runs on deploy. It delegates now, and a test holds it to that.
-
No eprint could publish a standard.site document. The write asked the author's PDS to validate a foreign lexicon, and a PDS that cannot resolve one rejects the record outright:
Unknown lexicon type: site.standard.document. Every attempt failed, which is why no eprint had one. The record was never at fault --site.standard.documentis published, and the shape Chive writes satisfies it -- so validation is now left to this codebase and its tests, here and for the two sifa writers, which had the same latent fault. -
A preview of an eprint looked like a placeholder. The card was given a title and one author name and drew them on an otherwise empty 1200x630 field, while the record's abstract, full author list, venue and keywords went unused. A preview reads as a paper when it carries the paper's own substance, so all of it is passed and laid out: the abstract fills the card the way a document's text fills Leaflet's, unreadable at thumbnail size and not meant to be read there. A record with none of it centres its title rather than stranding it against the top edge.
0.17.1 - 2026-09-03
- The deploy's citation re-matching never ran.
rematch-citations.tsis executed by the deploy as its ownnode dist/...process, outside the server entry points that install the reflect polyfill, and tsyringe throws at module load without it. So the step failed on every deploy — after the reindex, which meant the reindex's work stood but the citation graph was never refreshed. The polyfill is now installed by every script the deploy runs directly, and a test reads the workflow itself so the list cannot drift from what is actually executed.
0.17.0 - 2026-09-02
-
A linked dataset can now be the dataset, not just one corpus inside it. Layers 0.9.0 added
catalogReftopub.layers.eprint.dataLink, pointing at thepub.layers.catalog.collectionthat is a dataset's citable artifact. Chive read the earlier schema and passed through onlycorpusRef, which names a corpus — one record type within a dataset, and one that many datasets do not have at all. MegaAcceptability, for instance, is expressions and judgment sets with no corpus account, so no link to it could name anything Chive would render. Chive now carriescatalogRefandexperimentRefsalongsidecorpusRef, and the eprint page prefers the collection, because that is what a reader means by "the dataset".The Python snippet follows the same distinction: a collection is loaded with
lairs.data.collection.load_collectionand a corpus withload_corpus. Emitting the corpus loader for a dataset that has no corpus would hand the reader a call with nothing to call it on. The three data kinds 0.9.0 added —experiment,judgments,dataset— are labelled rather than shown as raw slugs.
-
Half the backlink integrations received no records at all. The relay decides what a consumer is sent; the local event filter can only narrow that stream, never widen it. The indexer admitted every observed foreign collection locally but passed no filter to the relay, so the consumer fell back on a hardcoded namespace list of its own that named only Chive, cosmik and margin. Leaflet, standard.site, Bluesky and calendar records were dropped upstream and never entered the process — the plugins loaded, subscribed and were never called, and cosmik worked only because it happened to appear in that list. The subscription is now always built from the observed set, the fallback names no foreign namespace so it cannot diverge from that set again, and a test asserts every observed collection is requested from the relay.
-
A backlink write abandoned the rest of the record.
BacklinkTrackingPluginemitsbacklink.createdandbacklink.deletedafter each write, and the plugin bus enforces emit permission against the plugin's own manifest. No backlink plugin declared those two hooks, so every write threw immediately after succeeding: the first reference on a record was stored, its remaining references were skipped, and the failure surfaced only as a warning. All seven plugins now declare what they emit, and a test holds each manifest to the hooks its base class raises. -
Every endpoint that returned a row with a
bigintid answered 500. PostgreSQLbigintarrives from node-postgres as a string, because a value past 2^53 cannot survive a JavaScript number. Passed through unconverted it failed output validation against lexicons that declare an integer, sopub.chive.backlink.list,pub.chive.import.searchand the claiming queries failed for exactly the rows they existed to return, and looked healthy whenever the result was empty. Every fixture in their tests carried a JavaScript number, so nothing exercised the conversion.The ids are converted where each row is mapped, and the tests now use the string the driver really returns. This is why no backlink had ever been observed through the API even where the pipeline had written one: a backlink appeared 28 milliseconds after its record reached the firehose, and asking for it returned an error.
-
A dataset linked by AT-URI rendered as a link that did nothing. A dataset published on Layers has no web address to give: its web routing is not settled, and the record is the durable identifier. So the AT-URI is stored in
repositories.data[].url, a field declared as a URI and rendered as an anchor — which produced a link a browser cannot follow, wearing an icon that promised it opened somewhere, badged "Other". Chive now tells an ATProto record reference from a web URL and renders it as what it is: the dataset's name, its URI, an attribution to Layers, and thelairscall that loads it. Ordinary web repositories are unchanged. -
An eprint page could not say why it showed no datasets.
listDataLinksreports whether an answer came from Layers or could not be obtained, so that "this paper has no linked data" and "we could not ask" are distinguishable. That signal reached the page and was discarded. The Data tab now says when the lookup failed. Tab visibility is unchanged: Layers being unreachable must not give every eprint a Data tab.
0.16.0 - 2026-09-02
- WhiteWind support. The service is dead and nothing ever wrote a backlink through it. The plugin, its collection subscription, its source type, its counts column and its rendering are gone.
-
Every OpenGraph image on the site returned an error. The main domain routes
/apito the API container, which strips the prefix before handling, so/api/og— a Next.js route, and the only one under that prefix — arrived at the API as/ogand answered 404. The preview card for a Chive link shared anywhere had a title and description but no image. A more specific Traefik router at higher priority keeps that one path with the frontend, the same carve-out the metrics endpoint already uses. -
Backlinks from every integration were rejected on insert. The
backlinks.source_typeconstraint listed names from before the plugins were rewritten against the lexicons their services publish, and the types added since were never added to it. A plugin writing a value the constraint does not list has its row rejected by PostgreSQL, which is indistinguishable from finding no reference at all. The constraint is now derived fromBacklinkSourceType, with a test tying the two together, and the backlink list renders every source type rather than the three it used to know. -
One unreachable PDS failed the whole deploy, and the records were never retried. The reindex exits non-zero if any record fails, and a user's PDS being down, rate-limiting or slow is an ordinary condition for an AppView — three records on a single host were enough. Because the reindex is not the last deploy step, everything after it was skipped, including the citation re-matching meant to keep the graph current.
A record that cannot be fetched is now handed to the index retry worker, which resolves the DID, re-fetches and indexes, backing off exponentially across ten attempts. The reindex finishes, the deploy proceeds, and the records are retried in the background. Should the queue exhaust its attempts, the periodic freshness scan selects records by how long ago they were synced, so one that was never fetched sorts to the front of the next scan — there is no state in which a record is stale and nothing will try it again. Failures that would leave the index wrong rather than stale, including unresolved field labels, still fail the deploy.
0.15.0 - 2026-09-02
- Code, data and materials have their own tabs on an eprint. They were rendered at the bottom of the Metadata tab, behind tags, enrichment, conference and funding — the last place on the page anyone would look for a repository, so eprints that recorded one were effectively hiding it. The Data tab gathers everything that counts as data for the paper: declared repositories, datasets linked on Layers, and supplementary files, which had been split across the metadata tab and each other. Each tab appears only when the eprint has that kind of resource, so nothing gains an empty tab.
- A linked dataset comes with the code that loads it. A Layers dataset is addressed by an AT-URI, which tells a reader the data exists but not how to open it. Each linked corpus now carries a copyable
lairssnippet with its own URI already in place, so the path from "this paper has a corpus" to "I have the corpus" is four lines. - A researcher's graph proposals appear on their profile. The section existed and rendered "Graph proposals are not yet available on profile pages" — a placeholder that shipped.
pub.chive.governance.listProposalsalready acceptedproposedBy, so the data was there; the section now lists what was proposed, how the community voted, and where each proposal ended up.
-
Leaflet documents were being read by nothing. The plugin was rewritten against Leaflet's published lexicons, its schemas vendored and its collections added to the observed set — and it was never registered with the indexer. The records arrived, the event processor forwarded them to the plugin bus, and nothing was subscribed. The same was true of the WhiteWind plugin. Both are registered now, and a test fails if a collection is observed with no plugin behind it.
-
Eprints linked from other publishers' standard.site documents produced no backlink. The plugin read only the two fields Chive's own documents use,
pathand the legacycontent.uri. Butcontentis an open union — the format does not enumerate block types and each publisher brings its own, so pckt posts, which aresite.standard.documentrecords holdingblog.pckt.block.*items, could link an eprint in their prose and go unnoticed. Any eprint reference in a document's body is now found, whether written as an AT-URI or as a link to the eprint's page, which is normalised back to the AT-URI so one work does not accumulate references under two identities. -
A shared eprint was never recorded on its standard.site document.
bskyPostRefexists so a paper's Bluesky thread is discoverable as its off-platform discussion, and the helper that writes it was reachable only through a hook no component used. Sharing an eprint now attaches it. -
The citation network was far emptier than the data supported. Citations are matched to Chive eprints once, while a document is processed, against whatever was indexed at that moment — and nothing re-ran it, so a reference to a paper that arrived later stayed unresolved forever. The graph only ever held edges that were discoverable in extraction order, and grew more incomplete as the corpus filled in behind it. Every deploy now re-resolves citations that have no match yet and creates the missing edges. It reads Postgres and writes the graph — no PDF fetch, no GROBID — and skips anything already matched.
-
Repositories recorded before the platform field was renamed showed a generic icon. The lexicon calls it
platformSlugand that is what the submission wizard writes, but older records carryplatformand Chive does not rewrite user records. Both are read now, so an older eprint shows its GitHub or OSF icon.
0.14.1 - 2026-09-02
- Affiliations were listed in no particular order. Merging the Chive profile with the sifa.id one walked the Chive record first and appended whatever sifa alone knew about — an order nobody chose, which on a list showing years read as unsorted. Previous affiliations now order by when the role ended and current ones by when it began, most recent first, with the primary affiliation leading whatever its date. A Chive affiliation carries no dates at all, so an institution both sources name takes its dates from the sifa role, undated entries sort last, and a profile with no dates anywhere is left exactly as its owner arranged it.
- A paginated eprint list could repeat, skip or misorder entries. The queries ordered by date alone, which is a partial order: PostgreSQL gives no guarantee about how it arranges tied rows and may arrange them differently for each query, and every page is a separate query. Publication dates are routinely recorded as just a month or a year, so ties are the norm — 23 of one author's 58 eprints share a timestamp with another. Ordering by the record's URI after the date makes the order total, and identical across pages.
- A profile links to the researcher's sifa.id page when they have one. The link appears only for a researcher whose repository actually holds sifa records, so it never points at an empty page, and it addresses the profile by DID rather than handle, which does not change when someone moves domain.
- A long bio is clamped to a few lines, with a control to read the rest. A long one used to push the affiliations, identifiers and eprint list off the screen. The control appears only when the text is genuinely clipped, measured rather than guessed from a character count, and re-measured when the element resizes: a bio that fits on a wide window clips on a narrow one.
- The bio field is a rich text editor, the same one the abstract uses, so
@mentions and#tags autocomplete and LaTeX and a preview are available. It was a plain textarea, which accepted the syntax the save path already detected but gave no way to discover it.
- A cache hiccup could strand a deployment. The staging deploy for 0.14.0 built the web image and pushed it to the registry, then failed on
error writing layer blob: failed to reserve cachewhile exporting to the GitHub Actions cache. The image existed and the job was marked failed, so staging never pulled it and sat two releases behind with nothing in the failure pointing at the cause. The cache export is now markedignore-error=truein both workflows that use it: a cache is an optimisation, and losing it should cost build time rather than a deployment.
0.14.0 - 2026-09-01
- The Chive bio is rich text, using the same item union reviews and abstracts already use — links, mentions, tags, LaTeX and the rest — rendered by the same renderer.
bioRichcarries the marked-up form andbiokeeps its plain text, so meta descriptions, OG images and search still get a string.
- A profile now states each institution once. A researcher can record their affiliations twice, in their Chive profile and in their sifa.id profile, and the page rendered both — the same university under "Affiliations" and again in a separate professional-profile card. The two are merged on the institution, so each appears once carrying what each source knows: the departments and ROR identifier from Chive, the role and years from sifa. Matching is deliberately conservative — case, punctuation and a leading "the" are ignored, but "Univ. of Rochester" will not be merged into "University of Rochester", because one extra row is a cheaper mistake than attributing a department to the wrong employer.
- The bio falls back through sources rather than always using Bluesky's. A Bluesky description is a personal one and often not professional. A bio written on Chive wins; failing that the sifa.id summary is used; the Bluesky description is the last resort. The settings field says so.
- Departments sit on their own line beneath the institution in previous affiliations, rather than after a dash that read as though the department were part of the university's name, and the institutions are spaced apart so a list of several is scannable.
- Four admin pages answered with a 500. The XRPC router validates every response against its lexicon, so a handler returning a field the lexicon does not name — or omitting one it requires — fails the request rather than merely mismatching.
getGraphStatsreturnedtotalNodes/totalEdgeswhere the lexicon requirednodeCount/edgeCount,getSearchAnalyticsreturnedtotalQuerieswhere it requiredtotalSearches, andlistWarningsandlistViolationswere required to return atotalthat nothing computed and no page read. In each case the handler and the page agreed with each other and the lexicon was the outlier, so the lexicons now describe what is actually returned. All 41 admin methods were mounted and authorizing correctly throughout; only the response shapes were wrong.
0.13.1 - 2026-09-01
- Author search reported the wrong number of eprints beside each author. The count was built by tallying how often an author appeared in the page of search hits, which is bounded by the page size and by the loop's own early break — so an author with 58 eprints was shown as having 1. It now reports how many eprints the author actually has, fetched for the whole page in one query, because author autocomplete runs this on every keystroke and a query per suggestion would be a round trip per suggestion per character.
0.13.0 - 2026-08-31
- A researcher's sifa.id profile appears on their Chive profile. sifa.id is an ATProto professional profile service whose records live in each user's own repository, so Chive reads their positions and education from the same place it already reads their eprints — neither service needs an account with the other. Employment with no end date shows as a current role and employment with one as a previous role, which is the split Chive's own profile already makes; education is read too, since for academics a PhD institution is an affiliation that older papers carry. A researcher with no sifa profile sees nothing, and an unreachable PDS costs the rest of the profile nothing. The schemas were read from the lexicons sifa.id publishes, resolved through its
_lexicon.sifa.idDNS record. - An eprint can be recorded on sifa.id as it is submitted. Writing
id.sifa.profile.publicationandid.sifa.profile.presentationDeliveryrecords saves entering the same publication twice. The record is written by the researcher's own agent to their own repository, as theirpub.chive.*records are, and itssameAspoints at the eprint's AT-URI so a sifa consumer can resolve back to the work — without a CID, so the reference follows the eprint's edits rather than pinning one version.
-
A mapping change now actually reaches production. Editing the index template did nothing to a running deployment: mappings are fixed once an index exists, index bootstrap returns early when the alias is already there, and the deploy's reindex copies documents into whatever mapping the index already has. So the author-name analyzer fix would have shipped and changed nothing. The template now carries a version, the deploy migrates when the live index reports a different one, and running it when they match does nothing — deciding by diffing the mapping instead would rebuild the index on every deploy, because Elasticsearch echoes back a normalised mapping that never equals what was sent.
-
Applying an Elasticsearch mapping change no longer means deleting the index. Mappings are fixed once an index exists, so an edited template reached a deployment only through a script that deleted
eprints-v1outright — search down for the length of a full rebuild.pnpm db:migrate:elasticsearchbuilds the next index version from the current template, copies the documents, and moves the alias atomically; search serves the old index throughout. The author-name fix below is a mapping change and needs this run against any existing deployment. -
Every autocomplete can now be operated from a keyboard and announced by a screen reader. Chive has around twenty of them and two implemented the WAI-ARIA combobox pattern; the rest offered their suggestions to a mouse only. Most handled Escape and no other key, so a keyboard user could open a list of suggestions and had no way to reach one. Ten delegate to
AutocompleteInput, which implements the pattern once; the nine that render their own grouped list — Chive events beside DBLP venues, personal nodes beside global ones, Chive institutions beside ROR — adopt a newuseComboboxhook that owns the keyboard and the ARIA wiring without touching their markup. All of them gain arrow keys, Home, End, Enter to commit, Escape to dismiss, and anaria-activedescendantnaming the current suggestion. A test over the whole directory now fails if a new autocomplete does none of the three. -
Two Enter behaviours changed with it.
fast-autocompletecommitted the first suggestion regardless of what the user had moved to, which was the only thing it could do; it now commits what is highlighted, and nothing when nothing is.affiliation-inputkeeps its free-text fallback — an affiliation in neither Chive nor ROR is still a real affiliation — so Enter with nothing highlighted still adds the typed value.
- A profile with several affiliations was a wall of repeated text. Each affiliation is a tree, and the header rendered one fully-qualified path per leaf — so three departments at one university produced three long lines that each began with the university's name, and on a phone each of those wrapped to two or three rows. The institution is now named once with its units listed beneath it, which is also how affiliations are conventionally written. Previous affiliations were badges holding a full three-level path, which wrapped into paragraph-shaped pills; they are now a grouped list.
- Long identifiers pushed the eprint page sideways. A DOI link was an
inline-flexelement, which cannot wrap, so a full DOI URL ran past the edge of a phone-width column; the same held for the source record link and RRIDs. The article carriedoverflow-x-hidden, which hid the symptom rather than the cause. Those identifiers now wrap. - Keyword pills had the same problem as field chips, and the "Fields:"/"Keywords:" labels sat inline with them, so on a narrow screen the chips were laid out in whatever width was left after the label. The labels now sit above the chips on a phone and beside them from
smup. - Field chips broke up on narrow screens. The badge they are built on never set
whitespace-nowrap, so a long discipline label wrapped inside the pill while the pill kept the vertical padding of a single line. Chips are now one line each, truncating at the container with the full label still available to assistive technology, and the gap between them is wide enough that neighbouring links are not one tap apart. - No document would open. The pdf.js worker was loaded from
cdn.jsdelivr.net, and the Content-Security-Policy added in 0.10.0 restrictsscript-srcto'self'— so the worker's dynamic import was blocked and every eprint failed with "Setting up fake worker failed". The worker is now copied out of the installedpdfjs-distintopublic/by the build and served from our own origin, which also means it can never drift from thepdfjs-distthe viewers load, and the viewer no longer depends on a third party being reachable. - Only PDFs were actually viewable. The submission wizard accepts ten manuscript formats and the eprint page sent all of them to the PDF viewer, so a Markdown, LaTeX, HTML, Jupyter or plain-text submission rendered as a corrupt-PDF error.
DocumentViewer, which renders five of those formats, existed and was mounted nowhere. The page now dispatches on the blob's MIME type: PDFs keep the annotated viewer with highlighting and inline review; HTML, Markdown, LaTeX, Jupyter and plain text are fetched from the author's PDS and rendered, with text selection still offering the review action; and DOCX, ODT, RTF and EPUB — which have no in-browser renderer here — say so by name and offer a download instead of failing. Downloads also carry the right extension, having previously been named.pdfwhatever the document was. - Author pagination never advanced.
totalwas reported as the length of the page just fetched rather than the author's eprint count, and the cursor is emitted only whenoffset + page.length < total— a comparison that could never be true. So no cursor was ever sent, the profile's infinite scroll saw no next page however many eprints an author had, and "My Eprints" showed the first twenty with no way to reach the rest. That page now uses the same paginated query as the public profile. - Searching for an author's name returned nothing. Two independent faults sat on top of each other.
authorsis mapped asnested, so its subfields are indexed as separate documents and cannot be reached from an ordinarymulti_match— yetauthors.namewas listed among the flat fields, where it matched nothing. Underneath that, the analyzer applied toauthors.nameused a keyword tokenizer, so "Aaron Steven White" was indexed as the single tokenaaron steven whiteand no query for a surname could match it even through a nested query. Author names are now tokenized, and searched through a nested query scored by the best-matching author. Author autocomplete was the visible casualty: it searches eprints and reads the authors off the hits, so it found none and fell through to Bluesky for every query. - The dashboard reported at most twenty eprints. It counted the length of the page it had fetched rather than reading the total the server sends, so anyone with more than twenty saw the wrong number beside two other counts that were right — and it disagreed with the profile.
- The eprint page sorted its reviews, endorsements and annotations on every request. All three are read as
WHERE eprint_uri = $1 AND deleted_at IS NULL ORDER BY created_at DESC, and the indexes coveredeprint_urialone — so PostgreSQL matched the URI, filtered the soft-deleted rows, then sorted, on every view. Partial indexes on(eprint_uri, created_at DESC) WHERE deleted_at IS NULLanswer the query from the index, already ordered. The busiest papers are the ones most often opened, so they were paying the most for it. - Search and endorsement pages issued one query per result. Both looped a single-record fetch over their results — a page of 25 search hits cost 25 round-trips to Postgres, a page of 50 endorsements cost 50 to read 50 titles. A batch getter already existed and issued a single
uri = ANY($1); both endpoints now use it. Search did this in two separate code paths, so fixing one would have left the endpoint unbatched for queries without text. - A large batch staleness check could not run at all.
checkBatchbuilt one query parameter per URI, and the PostgreSQL wire protocol caps a statement at 65535 of them — so a batch past that failed inside the driver before the query was sent, and every distinct batch size produced a differently-shaped statement for the planner to parse afresh. It now passes the list as one array parameter.
0.12.0 - 2026-08-31
Chive now reads and writes other applications' lexicons against what those applications actually publish. Several of the records it was already emitting turned out to be invalid, and several it was already subscribed to were never reaching it.
- Leaflet documents and comments are read for references to eprints. Leaflet's schemas are vendored under
lexicons/vendor/leaflet/, taken from its own lexicon repository. A document reaches an eprint by four routes and all four are followed: a comment'ssubject, awebsiteblock'ssrc, an inline richtext link, and astandardSitePostblock naming an eprint directly. - standard.site documents that describe an eprint are recorded as backlinks, whoever wrote them. This is also the mapping that lets a reference addressed to a document — a standard.site recommend, an embedded document block — resolve to the work it ultimately means.
- Talks and presentations appear as backlinks. A
community.lexicon.calendar.eventnaming an eprint among its URIs is evidence the work was presented, and where. Cancelled and postponed events are skipped. pub.chive.site.citationLink, a typed document-to-work link forsite.standard.document's reservedlinksunion, with a CiTO-style vocabulary. A consumer that knows CiTO can read a Chive citation without knowing anything about Chive. Opt-in.- The Bluesky post announcing an eprint is recorded on its standard.site document as
bskyPostRef, which makes the post's reply thread findable as the paper's off-platform discussion. - An MCP server over the public read API (
pnpm mcp): search, resolve-by-identifier, citations and reviews. It calls the same unauthenticated XRPC a browser does, so it holds no credentials and can be pointed at any deployment. Every tool is read-only. - Tangled repositories are a first-class code artifact. A repository hosted there is an ATProto record, so
codeRepositorygainsrecordUrialongsideurl— an address on the network rather than at one web host. - The external identifier resolver is documented for other applications at
docs/api-reference/resolving-identifiers.md.
- The firehose filter was dropping seven of the twenty collections Chive indexes. Its NSID validator required every segment to be lowercase. An NSID is a domain authority followed by a name, and only the authority is a domain label; the name segment is where camelCase lives. With strict validation on — which is what the indexing service sets —
pub.chive.eprint.userTag,eprint.relatedWork,annotation.entityLink,collaboration.inviteAcceptance,actor.profileConfigand both graph proposal types were rejected before the processor saw them. Each is in the indexed set, each has a handler, and the PDS scanner backfills each; only the live firehose path refused them, silently. The name rule now matches the grammar@atproto/syntaximplements. - Five registered backlink plugins could never be called. The event processor already forwarded foreign records to the plugin bus, and the plugins already subscribed; the filter rejected every collection outside
pub.chive.*upstream. Cosmik backlinks, connections, follows and link removals, and Margin annotations were constructed, subscribed, and unreachable. - Every
site.standard.documentChive had written was invalid. The lexicon requiressiteandpublishedAt; neither was written.contentwas an object where the schema has an open union, andvisibilityandcreatedAtare not properties at all. No standard.site consumer could accept such a record, which is why the cross-platform discovery this feature promised never appeared. Documents now carry the required fields and apath, sosite+pathis the canonical URL of the eprint page — which is how a reader verifies that document and page describe the same work. - Deleting an eprint still finds its documents. The lookup matched the invented
content.uri; it now matchespathand the legacy field both, because documents already in users' repositories carry the old shape. Updating one repairs it in place. /.well-known/site.standard.publicationadvertised a record that cannot exist. The collection is absent from the service repository, andselfis not a legal record key for a lexicon whose key istid. It is nowCHIVE_PUBLICATION_URI, and unset the endpoint answers 404 — an unconfigured publication rather than a broken one.- The Leaflet plugin matched no record that any repository holds. It tracked
xyz.leaflet.list, an NSID Leaflet does not publish, and parsed an invented shape; itsshouldProcessfiltered on avisibilityfield that does not exist, so even repointed it would have skipped everything. - The sign-in field had no accessible name.
FormControlpassesidandaria-*to its child;HandleInputaccepted none of them and forwarded none, so the label never associated and a screen reader announced an unlabelled text box on the first field of the login page. - The autocompletes could not be operated from a keyboard. Around twenty of them, of which two implemented the combobox pattern; the shared base implemented none of it and
node-autocomplete— behind every knowledge-graph field — handled Escape and no other key. A keyboard user could open a list of suggestions and had no way to reach one. Both now implement the pattern: arrow keys, Home, End, Enter, Escape, witharia-activedescendantand marked-up options. - Cancelling an admin operation did not stop it.
startOperationreturns an AbortSignal that the governance sync and DID sync handlers dropped, so the work ran to completion and reported success against a cancelled operation. triggerBackfillrecorded operations nothing ran. It calledstartOperationand returned, leaving an operation pending forever. It now names the endpoint that does the work for the requested type.- The Helm chart still set the governance PDS DID to
did:plc:chive-governance, which is not a PLC identifier. Every other configuration was corrected in 0.10.0; a Helm deployment would have carried it. A test now sweeps every tracked config rather than the ones someone remembers. - Integration tests ran under Vitest's 5-second default while talking to four datastores, so a slow runner failed the build with no defect behind it. The datastore-backed suite gets 30 seconds; the unit suite keeps the 5-second default.
- A type error in a test file removed the generated code reference from the documentation site, and the only symptom was a link check naming every page that pointed at the missing section.
- Dependency updates target
staging. Work reaches production through staging here, so nine dependency PRs had accumulated against a branch the normal flow cannot merge them into. traefikand the PDS image are pinned in production compose, to the versions production already runs.latestmeant acompose pullcould replace the ingress every request passes through without anyone choosing to.- The README names the plugins that actually register. It advertised GitHub, ORCID, DOI registration and Wikidata — the four that are written and not constructed by any service.
- A clean clone can typecheck:
pnpm lexicons:generateis documented as the step that must precede it. - E2E runs in CI again, for the unauthenticated project. Seventeen assertions of the form
expect(true).toBe(true)now assert something.
0.11.1 - 2026-08-30
-
Sign-in failed with
Failed to resolve identity, and record writes would have failed the same way. The Content-Security-Policy introduced in 0.10.0 setconnect-srcto the app origin and the Chive API, which is too narrow to run an ATProto client: the browser connects to hosts it discovers at runtime, and the policy blocked all of them. Handle resolution reads DNS overhttps://dns.googleand falls back to the public AppView;plc.directorysupplies the DID document naming the PDS; the OAuth authorization server is that PDS; and every record the app writes — eprints, reviews, mutes, Layers data links — is a direct browser-to-PDScom.atproto.repo.*call. External autocompletes against Crossref, DBLP, arXiv, ORCID and ROR were blocked for the same reason.connect-srcnow allowshttps:andwss:, which is the narrowest form that works: the set of PDS hosts is open by design and CSP cannot express "any host this document later learns about". Plaintext HTTP and non-HTTP schemes remain blocked, andscript-src,object-src,base-uri,form-actionandframe-ancestorsare unchanged.The 0.10.0 entry describing that policy said everything other than
script-srcwas enforced. That was wrong ofconnect-src, which was not enforcing a boundary but breaking the application.
0.11.0 - 2026-08-29
- Layers dataset links. Chive now reads and writes
pub.layers.eprint.dataLink, the record Layers defines to associate a dataset with an eprint. The submission wizard attaches a corpus, annotation layer or model output alongside supplementary files and code repositories, writing the records into the author's own PDS. Because a dataLink requires the eprint's AT-URI, the records are written after the eprint exists, one at a time, and a failure to write one neither rolls back the eprint nor stops the rest of the batch; the submitter is told which links did not land. - The eprint page shows those datasets, grouped by kind, with the section of the paper each belongs to. The list is read from the Layers AppView rather than indexed: Layers is authoritative for its own lexicons. Answers are cached in Redis for five minutes, the request times out after two seconds, and failures are not cached.
sourcedistinguishes an empty list from an unreachable Layers, so a paper with no datasets is not presented the same way as a paper whose datasets could not be fetched. - Dataset cards do not link out. Chive holds only the AT-URI of the record in its author's repository, and Layers' web routing is not settled.
- Server-side mutes.
pub.chive.actor.muterecords are indexed, andpub.chive.actor.listMutesserves them. The frontend continues to read mutes directly from the user's PDS, which is immediately consistent where the index lags the firehose. LAYERS_APPVIEW_URLconfigures the Layers AppView, defaulting tohttps://api.layers.pub.
- Governance PDS writing did not work in any configuration.
GovernancePDSWriterwas constructed only whenGRAPH_PDS_SIGNING_KEYwas set — a variable no configuration sets, and not a credential this system uses; the scripts that write to the governance PDS authenticate withGRAPH_PDS_PASSWORD. Independently, the writer discarded the key it was given and built an unauthenticated agent, so writes would have been rejected even with the variable set. It now logs in with the governance account, deferring authentication to the first write so an unreachable PDS at boot does not take the process down. SetGRAPH_PDS_PASSWORDto enable it;GRAPH_PDS_SIGNING_KEYandGOVERNANCE_SIGNING_KEYare removed. grantDelegationandrevokeDelegationreturned 503 on every call. Only the indexer constructed a governance PDS writer, so the API's was always undefined. Both processes now use the same configuration gate, and startup logs when governance writing is disabled and which endpoints will refuse.pub.chive.graph.getCommunitiesreturned an empty list on every request.GraphAlgorithmJob, the only producer for the cache the handler reads, was never scheduled. It now runs every 24 hours by default (GRAPH_ALGORITHM_INTERVAL_MS) and once on start.- Nine hooks truncated at 100 results without indicating it. Collections, mutes, personal graph nodes, node and field edges, endorsement kinds and citations each requested a single page from a cursor-paginated endpoint. A user with more than a hundred of any of them saw only the first hundred; an author muted past the hundredth record reappeared in the feed. All nine follow the cursor, and a walk stopped by the safety ceiling logs which caller it truncated.
- Every Chive author in the DID autocomplete was described as "Has Chive profile". The component read
author.hasEprints, whichpub.chive.author.searchAuthorsdoes not return, so the condition was always false. It reportseprintCountinstead. - Facet suggestions used node UUIDs in place of slugs, because
node.slugis not a fieldpub.chive.graph.searchNodesreturns. - The Layers data link fetch did not clear its abort timer, leaving a live two-second timer per eprint view.
- A type error in a test file removed the generated code reference from the documentation site. TypeDoc typechecks the whole program its tsconfig describes, and both tsconfigs included tests, so an unrelated test error made TypeDoc emit nothing and the Docusaurus build failed its link check instead of reporting the type error. The reference is generated from a tsconfig that excludes tests, and a typecheck over exactly the programs TypeDoc compiles runs before the build.
- The admin surface uses the instrumented API client. All of
use-admin.tscalledfetchdirectly, so roughly forty admin endpoints sent no request ID ortraceparentand did not appear in tracing, and their failures wereErrorrather thanAPIErrorwith a status. Admin calls now share the handler behind the generated client while still supplying the per-NSID service-auth token their endpoints require. Three form components calling Chive's own XRPC moved to the typed client; calls to Crossref, DBLP, arXiv and the Bluesky AppView are unchanged. scripts/publish-lexicons.tsrefuses to run from a dirty or untagged checkout, which would publish schemas belonging to no release.--dry-runis always permitted and--allow-dirtyoverrides the check.- Removed 11,161 lines of unreachable code: the blob proxy with its cache, CID verifier, request coalescer and R2 adapter, injected into every request context but reachable from no route; the notification service and its two push transports, with no importer; the enrichment worker, never constructed; the email and Zulip services, imported only by a script for testing them; and seven unreferenced Neo4j and Elasticsearch modules. Four compliance tests that scanned those files for repository writes now assert the files are absent. Blobs are read from the origin PDS on every request.
- Line coverage measures 49.8%, up from 47.5%, because the deleted code was untested and left the denominator. The ratchet is raised to match.
0.10.0 - 2026-08-27
Third tranche of the 0.8.0 backlog remediation. The theme of this one is signals that were reporting something other than what was happening: tests that asserted nothing, a coverage number measured against a subset of itself, manifests nobody had ever built, and an audit log that had never returned a row.
- Anonymous rate limits cannot be bypassed by setting a header.
getClientIPread the firstX-Forwarded-Forentry. That header is append-only — entries the client sent arrive first, and each proxy appends the address it saw — so the first entry is always a value the client wrote, and varying it per request gave every request a fresh window. It now counts backTRUSTED_PROXY_COUNT(default 1, matching Traefik) from the right, and discards the header entirely when it is shorter than the proxy chain rather than guessing.X-Real-IPandCF-Connecting-IPare gone as fallbacks for the same reason: single-value headers with no chain, indistinguishable from a client setting them. - The rate-limit window is evaluated atomically.
redis.pipeline()batches commands over the connection without making them atomic, so two concurrent requests could each read a count below the limit and each be admitted.zaddalso ran unconditionally before the check, so a client already over its limit kept writing entries: its window never drained, itsRetry-Afterkept moving out, and the sorted set grew for as long as it kept knocking. Both are now one Lua script, with the write inside the admit branch. Verified against a real Redis: fifty concurrent requests against a limit of ten admit exactly ten. - Only the account whose repository holds a record may alter or delete it.
deleteSubmissionandupdateSubmissionauthorised anyone listed in the record'sauthors[]— an array written by whoever submitted the eprint, so a submitter could hand delete rights to an arbitrary DID by typing it in. The narrower rule is also the only coherent one for an AppView: ATProto forbids cross-repository writes, so a co-author cannot change the record itself, and letting them change Chive's copy would leave the index disagreeing with the PDS that owns it. - A Content-Security-Policy is served on both sides. Neither had one, which is what let the stored-XSS hole fixed in 0.8.0 reach as far as it did. The API answers JSON only, so its policy is absolute. The frontend policy still carries
'unsafe-inline'for scripts, because Next.js inlines its own bootstrap and removing it requires per-request nonces threaded through the app — that work is not done here. Everything else is enforced, closing the injection paths that do not require running script. /readyno longer publishes the internals of every datastore. Each failing check returned the driver's message verbatim — connection strings, hosts, ports, index names — from an endpoint that is unauthenticated, exempt from rate limiting, and reachable from the internet. The real error still reaches the log with its stack.- The external-PDF proxy re-checks its allowlist at every redirect and bounds what it reads. The allowlist was checked once, on the URL the caller named, while
fetchfollowed redirects itself, so an allowlisted host could redirect the request to an address inside the cluster and have Chive fetch it and return the body. Bodies are now read through a counting reader that abandons the stream past 50MB, so a server that lies about itsContent-Lengthcannot exhaust memory. DISABLE_RATE_LIMITING=trueis ignored in production. One stray environment variable removed every limit from a live deployment — the same shape as the end-to-end auth bypass fixed in 0.8.0, with a comment as its only guard.- Administrative actions reach the audit log. Role grants wrote a Redis key; content deletions published to a channel with no subscriber. Neither reached
governance_audit_log, so the two actions an admin audit log exists for were the two it could not show.
pub.chive.admin.getAuditLogfailed on every call. The query selectedg.target_didandg.ip_address; neither column existed. A catch swallowed the error, returned an empty result, and logged that the table was unavailable — so the endpoint reported an empty audit log forever and sent anyone investigating to look for a table that was there all along. A second, independent fault in the same method: the count query had nogalias while sharing aWHEREclause written against one, so any call filtering by actor failed as well. Both paths to a row were broken.- All three Kubernetes overlays failed
kubectl kustomizeoutright and had never been applicable to a cluster. Each declared aconfigMapGeneratorwithbehavior: mergenaming a ConfigMap present in no base, and the development overlay additionally used deprecated multi-document strategic-merge deletes. Nothing in CI built them, so nothing said so. - Even rendering, the overlays pointed every pod at an address that did not resolve: service names were fully qualified into the
chivenamespace while each overlay deploys into its own, andnamePrefixrenamed every Service while the addresses in the ConfigMap stayed unprefixed. Addresses are now bare names, which resolve through the pod's search path;namePrefixis removed, since each overlay's namespace already isolates it and the prefix was what broke the names. - Citation extraction was silently disabled on Kubernetes. There was no GROBID manifest anywhere, though docker-compose has always run one and the indexer skips extraction when
GROBID_URLis unset — no failure, no log an operator would notice. - Three lexicon schema errors that
@ts-nocheckhad been hiding. The generator prepended it to all 436 generated files, exempting the entire lexicon type surface fromtsc. Removing it surfaced:pub.chive.actor.profileconstrainingorcidwith apattern, which the Lexicon spec does not define, so the field accepted any string;pub.chive.eprint.submissionputtingdefaulton a ref, which the spec allows only on primitives, so a record omittingpublicationStatusSluggot nothing rather than a preprint; and ten defs acrosspub.chive.richtext.*declaring$typethemselves, producing a duplicate-identifier error in every file they touched. pub.chive.graph.getCommunitiesanswers with an empty list on every request. 0.9.0 constructed thegraphAlgorithmCachethe handler reads, which was previously undefined;GraphAlgorithmJob, the only producer that writes into it, is still never constructed or scheduled. The endpoint is therefore served, successful, and empty. Not fixed here — scheduling the job or withdrawing the endpoint is a decision about whether community detection is a feature Chive offers.- The PDS scanner backfilled a different set of collections than the firehose indexes. Its hard-coded list had drifted both ways: it scanned
pub.chive.review.entityLink, which the event processor does not index, and omitted bothcollaborationcollections — so a repository scan could never recover a co-author invitation the firehose had missed, which is the failure a backfill exists to fix. pub.chive.eprint.tagwas in the indexed set, the event processor's dispatch and the scanner's, and there is no such lexicon anywhere. The record type ispub.chive.eprint.userTag. Listing the old name letsync.indexRecordaccept a manual index request for a collection with no schema to validate against.- An eprint's abstract could not be edited from the frontend.
useUpdateEprintre-listed the twelve fields it forwarded while the lexicon accepts fourteen;abstractanddocumentwere missing, with no error and no type failure to show it. The hook now takes its parameter type from the lexicon and forwards whatever it is given. toAtUrirejected the project's own record types. Its collection pattern was lowercase-only, so every camelCase NSID failed —pub.chive.eprint.userTag,pub.chive.graph.nodeProposal,pub.chive.collaboration.inviteAcceptanceamong them. The record-key charset also widens to the spec's, which includes:and~.- XRPC schema validation is no longer opt-in. The body parse and
validateXrpcInputsat together inside the JSON content-type check, so sending any other content type skipped both:inputstayed undefined and validation never ran. A method whose lexicon declares a JSON body is now rejected without one, and validation runs unconditionally. - Endorsement pages can no longer be empty while claiming more results.
contributionTypewas applied in the handler after pagination, so a page could return nothing whiletotal,hasMoreandcursordescribed the unfiltered set. It is now applied in SQL, to both the count and the page. - Seven collection handlers report an unconfigured feature instead of an empty one. Returning
[]is indistinguishable from a genuine empty result: a client rendered "no collections", and a searcher was told the query found nothing when nothing had been searched. - Every 500 is logged with the error that caused it. The request middleware passed a hardcoded
undefinedin the error slot, so server errors were recorded with a status and a duration and nothing else. - 400s are logged and 404s are not logged as errors. The XRPC error handler branched on error class:
ValidationErrormatched no branch, so every 400 went unlogged, whileNotFoundErrormatched the generic branch and filled the error dashboards with routine misses. It now branches on the status the client receives, which cannot develop the same gap when a class is added. - The metrics endpoint reports a failure instead of an empty registry. An empty catch around the
prom-clientimport returnedmetrics: [], indistinguishable from a registry with nothing in it. - Integration cards show "unavailable" rather than zero during an outage. GitHub, GitLab and Zenodo fetch failures produced zero-valued placeholders rendered exactly like real figures.
- The submission wizard keeps a draft. Nine steps of input — title, abstract, every author, fields, facets, funding — were discarded by a refresh, a back-navigation or a closed tab, with no way back. Files are deliberately not persisted, and the restore banner names what needs re-attaching.
- Canonical and Open Graph URLs come from the deployment origin. Every page staging served carried a canonical pointing at production.
- Four documented health endpoints did not exist, one of them inside a copy-pasteable Kubernetes probe snippet — following the documentation produced pods whose readiness probe could never pass.
- The test stack no longer reports success after a failed migration. It connected to a database the compose file does not create, discarded the error, and printed "✓ Migrations complete" regardless, leaving a running stack with no schema.
pnpm test:performanceinvoked a script that has never existed in this repository, so the performance workflow failed on its first command every time and the four k6 scenarios that do exist had never run.- The Docker
depsstage copiespnpm-workspace.yaml, without which pnpm resolves as a single package against a workspace lockfile. vitest.pre-deployment.config.tsusedpoolOptions.forks.singleFork, which Vitest 4 removed. Nothing typechecked the config files, so the key sat there being ignored and the suite ran in parallel forks against shared state — exactly what the setting existed to prevent.
- Coverage is measured over all of
src/. The v8 provider counted only files a test imported, so a module with no test simply left the denominator: coverage could rise by deleting a test and fall by writing the first one for a large file. Measured properly, the same suite covers 46% of lines rather than the 70% reported, and the full suite 52% rather than 80%. Nothing got worse; the figure was always this. The thresholds are now a ratchet set just under the real numbers, and the 80% bar in CLAUDE.md remains the target rather than a description. - Twenty-four compliance tests whose body was
expect(true).toBe(true)now check what their titles claim, mostly by reading the source and asserting the absence of a repository write — a claim about absence, awkward to demonstrate by executing code and exact to check by reading it. Where source is the wrong instrument they read the real artifact: the OTEL collector manifest must hold no ClusterRole, every verb on Promtail's must be a read, alert rules must contain nothing credential-shaped. - The rebuildability rule has an executable test for the first time. It replays a fixed firehose log through the real event processor against a real PostgreSQL index, snapshots it, deletes the index, replays the same log and requires the snapshots to match. The log contains an update and a delete, not only creates; the empty case is excluded; and the three columns normalised out are asserted to exist, so a rename cannot widen the exemption.
- The published OpenAPI specification is generated from the served one, and CI fails when they disagree. It was five months stale, missing fifteen handlers including the whole
com.atproto.repogroup and documenting six endpoints that no longer exist. - REST endpoints honour the
authandrateLimitthey declare, and all five HTTP methods the type admits. PUT, DELETE and PATCH were silently dropped — registered nowhere, a 404 at runtime with nothing said at startup. - PDS endpoints resolve through the shared, cached
DIDResolverrather than two hand-rolledplc.directoryfetches with no cache and no timeout. - The test suites default to the Docker test stack's database. The documented local flow failed at migration time with
role "chive" does not exist, a message that points at authentication rather than at the database name responsible. - CI skips build-tool installation when the compilers are already present (30–60s in every job, six times over) and keys the Turbo cache on the lockfile and branch rather than the commit SHA, which could never hit.
- Unit tests for the three modules that decide whether firehose events survive — the cursor manager, the event queue and the dead letter queue — and for the four on the trust boundary: the service-auth verifier, the DID verifier, and the OAuth state and session stores. None had a test. The verifier is the same file whose missing lexicon-method check was accepted for four months.
- The OpenAPI type generation: a 648KB
schema.generated.tsthat nothing imported, was gitignored, and required a live server on port 3001 to produce — so CI could neither generate it nor notice it was missing, while contributors were told to run it after every API change. - The dependencies and type interfaces the 0.9.0 second-factor removal left behind —
otplib,@otplib/*,@simplewebauthn/server, and 720 lines of interfaces exported from the barrel and imported by nothing. - The
vm2 → isolated-vmpnpm override.vm2is not in the dependency tree, so it was inert — and actively harmful if a transitive dependency ever pulled it in, since the APIs are incompatible and a version conflict would have become a runtime crash. - The second Redis client.
rediswas a production dependency used by one script while thirty modules useioredis. - Two exported builtin-plugin registries, neither referenced, each listing eight of twenty-eight plugins — and a different eight from the five
src/index.tsactually instantiates. - 1,350 lines of stub-implementation catalogues describing code that no longer exists, replaced by a note recording the one gap that is still real.
TRUSTED_PROXY_COUNTdefaults to 1. That matches every current deployment. A deployment behind a different number of proxies must set it, or anonymous rate limiting buckets all traffic under one key.NEXT_PUBLIC_SITE_URLshould be set on staging. It defaults to the production origin, which is the previous behaviour.- One migration.
1742000000000_admin-audit-log-columnsadds two nullable columns and widens a CHECK constraint. Itsdowndeletes rows carrying the new actions before restoring the narrower constraint, so rolling back loses audit history. - The lexicons need republishing to the governance PDS.
scripts/publish-lexicons.ts --dry-runreports seven changes against the live PDS:pub.chive.actor.profile,pub.chive.discovery.settings,pub.chive.eprint.submission,pub.chive.richtext.defsandpub.chive.richtext.facetsupdated;pub.chive.actor.profileConfigandpub.chive.claiming.dismissSuggestioncreated. Editing a lexicon file in this repository does not update the records the PDS serves.
0.9.0 - 2026-08-27
Second tranche of the 0.8.0 backlog remediation: fourteen further changes.
- Plugin loading refuses third-party code rather than importing it into the host process.
loadPlugindynamically importedmanifest.entrypoint, where it ran with the service's full privileges — filesystem, network, and the database credentials in the environment — while the plugin interfaces described isolated-vm isolation and permission enforcement. BothexecuteInSandboxandenforceNetworkAccessare implemented and have zero call sites: no plugin code has ever run inside an isolate. Nothing calledloadPlugin, so this was a trap rather than a live vulnerability, and it is now sprung loudly instead of left armed.CHIVE_ALLOW_UNSANDBOXED_PLUGINS=trueoverrides it, named for what it grants. - The frontend no longer offers its end-to-end authentication bypass in a production build. Setting one
localStoragekey made the client sendX-E2E-Auth-Did, which the API turns into an identity — andX-E2E-Auth-Admininto an administrative one — so a console one-liner on the live site was enough. The API stopped honouring those headers in 0.8.0, but a bypass that depends on the other side refusing it is not a control.
getCommunitieshas a cache to read from. Two handlers readservices.graphAlgorithmCache, whichServerConfighad no field for and nothing constructed, so it was always undefined and the endpoint returned an empty list on every request — while the graph algorithm job wrote precomputed results into the same cache that nothing read. Corrected in 0.10.0: this fixed the plumbing and not the supply.GraphAlgorithmJobis the only thing that writes into that cache and is still never constructed or scheduled, so the endpoint reads a cache nothing fills and returns an empty list — a well-formed, successful, empty answer instead of an undefined-service one. Saying it "returns data" was too strong.- Endorsement views carry the record CID. Handlers returned the literal string
'placeholder'for a field the lexicon marks required, with a comment claiming the CID was not stored; it has always been stored and the queries simply did not select it, so optimistic-concurrency writes were comparing against a constant that could never match. - Seven fields the search mapper emits are mapped explicitly, as
nestedwhere they are arrays of objects. Elasticsearch inferredobjectfor them, which flattens: a query for one funder's grant number matched across the whole array, so an eprint funded by A with grant X and B with grant Y matched a search for "A's grant Y". Wrong answers indistinguishable from right ones. - Mapping changes can reach a live index.
bootstrapIndexreturns early when the alias exists, and the only path that applied a change deletedeprints-v1outright — full search downtime for the length of a reindex, no prompt, nothing to fall back to.migrateIndexToCurrentMappingbuilds the next index version, copies the documents, and moves the alias in a single atomic action; the previous index is kept, so the migration is reversible. - The trending page is fetched in one query and its authors hydrated once. It issued a
getEprintand a separate call to the public Bluesky appview per entry — 40 network round trips for 20 entries — and silently dropped authors past the first 25 of each eprint. - KaTeX loads only when LaTeX is rendered. Roughly 280KB was imported at the top of the rich-text renderer, so browse, search, trending and author listings all shipped a typesetting library for content that rarely contains a formula.
- The Kubernetes manifests reference images that exist. They named bare
chiveandchive-frontend, which resolve against Docker Hub, while CI publishes toghcr.io/chive-pub/chive; overlays set onlynewTag, nevernewName; and the base pinnedlatest, which CI does not produce. No environment could pull an image.
- The XRPC method verb is resolved once, so the OpenAPI specification and the runtime router cannot disagree. They decided it from different sources, and because the frontend client is generated from the spec, a disagreement became a 404 at runtime rather than a build error.
- Profile lookups go through one cached hydrator. Handle, display name and avatar were fetched from the public Bluesky appview at fourteen call sites, two of them byte-identical private methods, none of them cached — so a page of reviews re-fetched the same authors on every request.
- The pre-deployment suite skips when the external services it verifies cannot be reached, rather than failing. That job is a required check, so a third-party outage previously blocked every merge in this repository however unrelated the change. A service that responds incorrectly still fails the build; only unreachability skips, and the skip says loudly that nothing was verified.
- Every environment variable the code reads is documented — 75 of 103 were not. Three clusters are marked as inert rather than presented as working configuration: the R2/CDN variables select an adapter that is never chosen, the governance PDS writer is never constructed, and the SMTP path is never invoked.
- The second-factor authentication layer: WebAuthn, TOTP and JWT session management, 2,334 lines plus a 688-line authentication service, and the two tables built for it. It was unreachable — no route, handler or service imported it, and credentials lived in Redis under TTLs rather than in those tables — so no user could enrol, because no endpoint existed to enrol through. Chive does not offer 2FA.
document_base64from the search document, an implemented and tested path for putting a base64 document body into Elasticsearch, contradicting the rule that only BlobRefs are stored. Nothing populated it.- The
repo:pub.chive.graph.fieldProposalOAuth scope, for a record type renamed to node/edgeProposal that has no lexicon.
0.8.1 - 2026-08-26
- The Prometheus scrape endpoint is no longer reachable from the public internet.
/metricsshipped in 0.8.0 unauthenticated by default, which is the usual arrangement for a metrics port on a private network — but Chive's API is fronted by two public Traefik routers, so on this deployment it was world-readable: request rates, per-endpoint latencies and error counts, and queue depths. It was serving 200 to anonymous requests after 0.8.0 deployed. Both public routes now deny the path at the edge, which holds whether or notMETRICS_TOKENis configured, and Prometheus scrapes the API directly over the compose network where it never traverses Traefik.
0.8.0 - 2026-08-25
Remediation sweep against the 0.8.0 backlog. Fourteen changes, each with tests pinning the specific failure. A recurring shape runs through them: work that ran, reported success, and had no effect — telemetry recording into an SDK that was never started, metrics nothing could scrape, deletions announced to a subscriber that was never written, precomputed graph results with no reader.
- Service auth tokens are verified against the method being called. A JWT's
lxmclaim scopes it to one lexicon method, and the verifier has always accepted a method to check against — the middleware passed none. The claim was decoded, copied intouser.scopes, which nothing reads, and never enforced, so a token minted forpub.chive.metrics.recordViewwas accepted atpub.chive.admin.deleteContent. Any holder of any valid token could call any endpoint their roles allowed. - PDS registration requires authentication and is bound to the caller's own identity. The handler was
auth: 'optional', and a registered host is later enumerated by the scanner, which indexes whatever repos that host claims to hold. The ownership check resolves the caller's DID document and fails open when resolution is inconclusive, which is recorded rather than hidden. - Server-side request forgery through PDS registration and
did:webresolution is blocked. Both fetched caller-supplied hosts with no scheme allowlist, no private-address block and no redirect cap, reaching cloud metadata at169.254.169.254, loopback and RFC 1918 services. Every resolved address is checked, not just the hostname, and redirects are refused. - The E2E authentication bypass cannot be enabled in production.
X-E2E-Auth-Didsupplies an identity andX-E2E-Auth-Admin: truegrants administrative access, with both header names in the production CORS allowlist; an unset environment variable was the only thing between a deploy and an open admin door. It is now gated onNODE_ENVas well, and the process refuses to start if the flag is set in production. - Stored cross-site scripting through JSON-LD is closed. The Schema.org payload is rendered with
dangerouslySetInnerHTMLand carries eprint titles, abstracts and author names taken from user-controlled PDS records;JSON.stringifydoes not escape<, so a title containing</script>closed the element and everything after it parsed as markup. - CodeQL, a dependency audit and a Trivy filesystem scan now run on every pull request and weekly. The repository had no scanning of any kind despite the architecture overview describing some. CodeQL gates; the dependency audit reports without failing, because the tree carries 343 known advisories (8 critical, 141 high) and a gate nobody can satisfy is one that gets disabled. Dependabot opens the update pull requests.
/readyreports the state of its dependencies. Each probe was raced against a timeout with.catch(() => undefined)applied to the racer, so a dependency refusing connections rejected fast, resolved toundefined, and was recorded as passing. The endpoint answered 200 with PostgreSQL, Elasticsearch or Neo4j down, and Kubernetes kept routing to the pod. Only a probe that hung past the timeout could ever trip it.- The admin full reindex no longer destroys indexed fields. It rebuilt each search document from a hand-rolled nine-field projection, and Elasticsearch replaces whole documents rather than merging, so DOIs, publication status, external identifiers, funding, repositories, related works, supplementary materials, licence and document metadata were wiped from every eprint the reindex touched. It now uses the same mapper as the reindex script. Facets remain empty, because they live on the PDS record and not in the index.
- Records deleted from their PDS are removed from the index. The freshness worker emitted
record.deletion_detectedand returned success; no subscriber to that event was ever written, so the scan reported a deletion and deleted nothing.PDSSyncService.markAsDeletedwas already among the worker's own dependencies. - Telemetry is started.
initTelemetrywas referenced only in its own documentation, so the OpenTelemetry SDK never initialised, everywithSpanexecuted its callback recording nothing, and no OTLP export happened. Both the API and the firehose indexer start it, since they are separate processes. - Request rate, latency and error metrics exist and can be scraped. The counter and histogram had no emission site — the middleware computed status and duration and only logged them — and the only exposure was an admin-authenticated XRPC method returning JSON, which Prometheus can neither authenticate against nor parse.
GET /metricsnow serves the exposition format, exempt from rate limiting, with optional bearer-token protection. - Cancelling a long-running admin operation cancels it.
startOperationreturns anAbortSignalthat all seven trigger handlers discarded, socancelBackfillflipped the operation's state in Redis while the loop ran to completion. The four handlers driving a loop locally now honour it, including both loops of the reindex and citation extraction. - Soft-deleted eprints no longer appear in author profiles, the counts beside them, field browse, or tag and keyword browse. The partial index the soft-delete migration created for exactly this filter was never used by any read path. The tag lookup needed a join rather than a predicate, since a tag row carries only the eprint URI.
- Eprint deletion is reversible and reconcilable. It hard-deleted the PostgreSQL row and then removed the Elasticsearch document best-effort; when that failed the row was already gone, so nothing recorded that a document still needed removing and no sweep could find it. Deletion now marks the row, and
reconcileDeletedFromSearchre-issues the removal. - A verified ORCID iD survives indexing. Verification wrote only to
authors_index, which is rebuilt from the firehose, and both profile upserts assignedorcidstraight from the incoming record — so a verified value was overwritten, usually with null, on the next profile update. An author who verified before being indexed lost it outright. Verification is now stored in its own table and seeded into new rows. - The child-facet hierarchy resolves.
getChildFacetsasked Cypher for*1..$maxDepth; a parameter is not accepted as a variable-length bound, so the query was a syntax error and the method threw on every call. - Recommendation and collaboration queries match the labels the write side creates. Fields are created as
(:Node:Field)and were read as(:FieldNode); authors are created as(:Node:Object:Person)keyed onmetadata.didand were read as(:Author {did}), so collaboration strength was null for every pair of authors who had in fact collaborated. Cypher returns no rows for a label that matches nothing, so both read as "no data yet". Interest-based paths remain empty: nothing createsINTERESTED_INat all. - Precomputed community and trending results have somewhere to be read from. Two handlers looked for
services.graphAlgorithmCache, whichServerConfighad no field for and nothing constructed, sogetCommunitiesreturned an empty list on every request while the graph algorithm job wrote results nobody read. Corrected in 0.10.0: the cache was wired up in 0.9.0, not here, andGraphAlgorithmJobstill runs nowhere, sogetCommunitiesremains empty. Both this entry and 0.9.0's claimed more than was done. - Trending pagination advances. The cursor was parsed only to build the next cursor and never passed to either data source, so every page returned the same entries while the cursor climbed.
- The ORCID verification flow resolves. The callback is registered at
/v1/auth/orcid/callbackwhile the default redirect URI pointed at/api/v1/..., a prefix only one Traefik router strips. - Methods declared as procedures are served on POST. The router ignored a handler's
typewhen no lexicon was registered, so aprocedurewas mounted as GET and its POST callers received 404.pub.chive.claiming.dismissSuggestion, the concrete victim, also gains the lexicon it never had. - Author autocomplete issues one query per page instead of one per hit — up to 75 sequential round trips per keystroke — and faceted browse one query per facet instead of one per edge.
- Search is billed against the relaxed rate-limit tier. The autocomplete list named
pub.chive.search.searchSubmissions, a method that does not exist; the real NSID ispub.chive.eprint.searchSubmissions, so search never matched and every anonymous request took the low tier. - Endorsement views carry the record CID. Handlers returned the literal string
'placeholder'for a field the lexicon marks required, with a comment claiming the CID was not stored — it has always been stored, and the queries simply did not select it. Optimistic-concurrency writes were comparing against a constant that could never match. - The WhiteWind backlink plugin tracks
com.whtwnd.blog.entry, the collection that exists. It subscribed tocom.whitewind.blog.entryand so could never have matched a post. - The governance PDS DID is validated at load. Every environment file set
did:plc:chive-governance, which is not a PLC identifier, overriding the correct default — so the governance sync resolved nothing and imported an empty graph. An ill-formed value now fails startup rather than being carried. - Thread loads no longer scan the whole review table.
parent_commentcarries a foreign key with no index, and PostgreSQL does not index foreign keys automatically. - A second paper login within five minutes no longer hangs. The popup's poll interval and timeout lived only in the promise closure, so the success path could not clear them and a stale timeout closed the next attempt's popup, leaving its promise unsettled.
- Manual reindex accepts every collection the firehose indexes. Three lists described "the collections we index" and disagreed;
sync.indexRecordaccepted 13 while the event processor handled 20, so seven were unrecoverable by manual reindex —pub.chive.graph.edgeProposalamong them. One list now serves both, derived from the event processor's own dispatch and asserted by test. - The scheduled health check probes
/readyas well as/api/health. The latter is a static 200 that reports nothing about dependencies, so a datastore outage was invisible to monitoring. pnpm testruns the backend suite. The root package is not a workspace member, soturbo testreached only the frontend and the command exited zero having run none of the 4,000-plus backend tests.- The developer test stack no longer collides with production containers, and the deploy sweep excludes it. Both used
chive-names —chive-grobididentically — anddocker ps --filter "name=chive-"matched the test stack, so a deploy could delete it mid-run. - Frontend coverage is measured and enforced. The config declared no thresholds and CI ran the suite without
--coverage, so the stated 70% bar was unenforced end to end; the real figure is 41%. Thresholds are set just below current levels as a ratchet, and both configs now record the gap to the documented bar rather than a bare TODO. - Every environment variable the code reads is documented — 75 of 103 were not. Three clusters are marked as inert rather than presented as working configuration: the R2/CDN variables select an adapter that is never chosen, the governance PDS writer is never constructed, and the SMTP path is never invoked.
- The XRPC method verb is resolved once, so the OpenAPI specification and the router cannot disagree and generate a client for a verb the server does not serve.
- The second-factor authentication layer. WebAuthn, TOTP and JWT session management — 2,334 lines plus a 688-line authentication service — were unreachable: no route, handler or service imported them, and credentials were held in Redis under TTLs rather than in the tables built for them. No user could enrol, because no endpoint existed. Chive does not offer 2FA; the code and its two tables are gone rather than completed.
document_base64from the search document. It carried a base64 document body into Elasticsearch — implemented, typed and tested — which contradicts the rule that only BlobRefs are stored. Nothing populated it.- The
repo:pub.chive.graph.fieldProposalscope, for a record type renamed to node/edgeProposal that has no lexicon.
0.7.1 - 2026-08-24
- Node subkinds are validated before being interpolated into Cypher.
subkindToLabelonly capitalised the hyphen-separated parts, so parentheses, whitespace and comment markers passed intoMATCH (n:Node:<label>)unaltered, andsubkindis caller-supplied on the unauthenticatedpub.chive.graph.listNodesandpub.chive.graph.getHierarchy. The helper was also duplicated, unguarded, across two files feeding nine interpolation sites; both now route throughsrc/storage/neo4j/labels.ts, which rejects anything that is not a plain identifier. - Proposals resolve by record key, so proposal detail pages and votes load.
getProposalByIdcast whatever identifier it received to anAtUriand matched onuri, while every route and list link carries the record key — a value that never equals a full AT-URI. No proposal page could load andgetUserVotefailed identically.IGraphDatabasegainsgetProposalByRkey, which falls back to the URI suffix so proposals indexed before the record key was persisted resolve without a migration. Closes #89. pub.chive.governance.listVotesreturns the votes on a proposal. It synthesisedat://chive.governance/pub.chive.graph.fieldProposal/<id>— an authority that is not a DID, and a collection that does not exist — so it matched no vote and always returned an empty list.- New proposals are indexed immediately through
pub.chive.sync.indexRecord, as every other user write already was.pub.chive.graph.nodeProposalandpub.chive.graph.votewere not accepted by that endpoint, so a proposal was only readable once the firehose delivered it. - The governance sync no longer clears the knowledge graph before it has the records to replace it with. An undefined
GRAPH_PDS_DIDrepository variable interpolated to an empty string, which overrode the correct built-in default and failed every request withParams must have the property "repo"— after the graph had already been wiped. Deploys therefore left Neo4j empty, and the eprint reindex that followed resolved every field label to a raw UUID. - Field labels survive a reindex that cannot reach the knowledge graph.
resolveFieldLabelsreturns the original UUID when Neo4j has no matching node and swallows the error that caused it, so a racing or failed lookup overwrote correct labels. The reindex now waits briefly for the graph to populate and preserves the label already stored in PostgreSQL rather than downgrading it. - The field label resolution job mirrors repairs into Elasticsearch. It only ever wrote to PostgreSQL, while browse and search read from the search index, so repaired labels never reached the UI.
- The PDS scanner can reach relay-connected servers.
getPDSesForScanrequiredis_relay_connected = FALSEand every registered PDS is relay-connected, so the scheduler ran every 15 minutes and scanned nothing. Records from those servers normally arrive over the firehose, but a relay outage longer than the relay's backfill window skips events permanently, and this scan is the only mechanism that can find them. They are now ranked last rather than excluded. - A PDS wedged in
scanningby a crashed scan is reclaimed after an hour. Nothing cleared that status and it was absent from the selection query, so such a server was excluded from every future cycle. - Backend services report their real release version.
npm_package_versionis unset when a container starts Node directly, so/health, structured logs and OpenTelemetry resources reported0.0.0in every deployed environment.
- The production deploy no longer re-injects database credentials into the reindex and governance sync steps, using the container's own environment instead. Re-interpolating them risked drift from the values the running service uses, and an undefined variable silently overrode a correct default.
- A failed Elasticsearch reindex or governance sync now fails the deploy instead of emitting a warning and reporting success.
0.7.0 - 2026-08-24
- Firehose consumer reconnects indefinitely with capped backoff and a WebSocket keepalive heartbeat, so a relay outage or a half-open socket can no longer wedge ingestion permanently. A single relay
503on 2026-05-18 killed the production consumer for three weeks without surfacing anywhere. - Indexer health endpoint on
INDEXER_HEALTH_PORT(default 3001) reporting per-relay connection state, with a watchdog that exits the process when the consumer stays unhealthy past its tolerance. The container healthcheck probes it instead of runningtrue. scripts/publish-lexicons.tspublishes thepub.chive.*lexicon schemas idempotently, so permission-set edits reach the PDS that resolves them.
- The lexicon publisher targets the dedicated lexicon account on the governance PDS (
lexicons.governance.chive.pub) rather than thechive.pubBluesky bot account, which exists only for posts.LEXICON_PDS_URLandLEXICON_PUBLISH_IDENTIFIERstill override both. transition:genericis no longer requested anywhere. Scope resolution fails closed to the ATProto base scope when session scopes are unavailable, leaving authorization entirely on thepub.chive.*permission sets. Existing sessions keep their old scopes, so users must re-authenticate.
- Deleting an eprint removes it from Chive's index immediately rather than waiting on the firehose, and the frontend deletes the dual-written
site.standard.documentrecords alongside it. - The deploy's Elasticsearch reindex prunes eprints that are gone from their PDS instead of failing on orphaned index rows, which had made the production deploy unrunnable.
- Backend services report their real release version.
npm_package_versionis unset when a container starts Node directly, so/health, structured logs, and OpenTelemetry resources had all reported0.0.0in every deployed environment.
0.6.3 - 2026-05-12
- OAuth permission sets now grant the rpc lexicons backing ORCID verification, admin endpoints, claiming flows, and profile-config writes. Production had been silently rejecting every
getServiceAuthTokencall since 0.6.0 droppedtransition:genericwithout enumerating the rpc grants the corresponding lxm need. Closes #85. - Resolved the audience-format contradiction between
@atproto/oauth-scopes(whoseisAtprotoAudiencevalidator requires<did>#fragment) andcom.atproto.server.getServiceAuth(whose lexicon rejects anaudcontaining a fragment) by settingaud: "*"on the rpc permissions inside the fourpub.chive.{basicReader,authorAccess,reviewerAccess,fullAccess}permission-set lexicons. Frontend now requestsgetServiceAuthwith the plain DID and matches the wildcard rpc grant at the PDS. - Retry
com.atproto.server.getServiceAuthonce when the user's PDS responds withuse_dpop_nonce. The OAuth client's auto-retry occasionally leaks the nonce-mismatch error through to caller code on the first request against a previously-unseen origin; the explicit retry consumes the freshly-issuedDPoP-Nonceheader on the second attempt.
- Bumped
@atproto/oauth-client-browserfrom 0.3.37 to 0.3.42 to pick up DPoP-handling fixes from@atproto/oauth-client0.5.12–0.6.1. - Production now emits
include:pub.chive.*permission-set references instead of individualrepo:pub.chive.*scopes (NEXT_PUBLIC_USE_PERMISSION_SETS=true). The consent screen shows one named entry per Chive permission set rather than one row per collection.
0.6.2 - 2026-05-07
- Hybrid OAuth scope layout: client metadata now emits
include:scopes for cooperating apps that publish a covering permission-set lexicon (Margin'sat.margin.authFull, Standard.site'ssite.standard.authFull, Semble'snetwork.cosmik.authFull), and falls back to individualrepo:scopes only for gaps Semble's authFull omits (network.cosmik.connection,network.cosmik.follow) and for Bluesky (which publishes no covering set). Collapses the consent screen from a wall of opaque collection names to one named entry per cooperating app, each with publisher-authored title and detail copy.
- Renamed Chive's permission-set lexicons from
pub.chive.auth.{basicReader,authorAccess,reviewerAccess,fullAccess}topub.chive.{basicReader,authorAccess,reviewerAccess,fullAccess}. ATProto'sIncludeScope.isAllowedPermissiononly honorslxm/collectionreferences that share the permission set's group prefix (everything up to its last dot). The four-segment names had a group prefix ofpub.chive.auth.and silently dropped every referenced collection; the three-segment names authorize the fullpub.chive.*namespace, matching Bluesky'schat.bsky.authFullChatClientprecedent. Permission set records now live inlexicons/permission-sets/(excluded from@atproto/lex-clicodegen, which can't generate types forpermission-setdefinitions). - Conformed Chive's Margin dual-write to Margin's actual published lexicons: a single
at.margin.notecollection (W3C Web Annotation Data Model) with themotivationfield distinguishing comment, highlight, and bookmark. Replaces the Chive-fictionalat.margin.annotationandat.margin.bookmarkcollections that Margin's AppView never indexed.MarginAnnotationsPlugin,MarginHighlightsPlugin, andMarginBookmarksPluginconsolidated into a singleMarginNotesPlugin. Therecord-creator.tsMarginAnnotation*types/functions renamed toMarginNote*;createMarginBookmark/deleteMarginBookmarkcollapse to thin wrappers that forward to the note machinery withmotivation: 'bookmarking'. - Lead permission-set detail strings with eprints (the primary Chive use case) instead of knowledge-graph governance.
- Owner-private collection reads in the dashboard hooks (
useMyCollections,useCollection,useCollectionsContaining,useSubcollections,useParentCollection,useCollectionFeed) now use the authenticated client. The owner-side visibility filter onpub.chive.collection.listByOwnerand friends gatesunlistedcollections to the authenticated owner; the unauthenticated client masked the viewer as anonymous and hid their own collections after reload. pub.chive.collaboration.listInvitesnow requires authentication and rejects queries that aren't scoped to the caller (invitee = me,inviter = me, orsubjectUriauthored by me). Closes an enumeration gap.
- "Created a new community/collection but it disappears on reload" (#79) for users who picked the
unlistedvisibility option in the wizard.
0.6.1 - 2026-05-04
NEXT_PUBLIC_USE_PERMISSION_SETSbuild-time env var. When set, both client metadata and the OAuth login request emitinclude:pub.chive.auth.*permission-set references instead of individualrepo:pub.chive.*scopes. Staging ships with the flag on so we can validate the permission-set flow against the now-live_lexicon.<sub>.chive.pubDNS TXT records.
- Handle resolution falls back to the public Bluesky AppView's
com.atproto.identity.resolveHandleXRPC when DNS-over-HTTPS returns no record. Direct browser fetches ofhttps://<handle>/.well-known/atproto-didare blocked by CORS in nearly every case (most identity-publishing servers don't setAccess-Control-Allow-Origin); the AppView runs both ATProto resolution methods server-side and serves a permissive CORS policy. Unblocks Eurosky users (luismmontilla.comand similar) who publish identity only via the HTTPS path. - OAuth client metadata
logo_urinow points at/chive-logo.svg(was pointing at/logo.pngwhich 404'd), so the consent screen header renders the Chive logo instead of a generic placeholder.
- "Failed to resolve identity: " sign-in error for users whose handle is published only via
.well-known/atproto-didrather than a DNS TXT record.
0.6.0 - 2026-04-24
- Individual
repo:pub.chive.*scopes for all 22 Chive collections (eprint, actor, review, annotation, graph, discovery, collaboration namespaces) - External cross-post scopes for
app.bsky.*,network.cosmik.*,at.margin.*, andsite.standard.*collections - Permission-set lexicon schemas at
pub.chive.auth.{basicReader,authorAccess,reviewerAccess,fullAccess}(served but not yet requested in the OAuth flow) did:web:chive.pubDID document at/.well-known/did.json, host-aware for staging vs production- Minimal
com.atproto.repo.*XRPC surface (getRecord,listRecords,describeRepo) that serves Chive's 219 lexicon JSON files ascom.atproto.lexicon.schemarecords with real DAG-CBOR CIDs, enabling NSID resolution for any ATProto service
pub.chive.collaboration.inviteandpub.chive.collaboration.inviteAcceptancelexiconsCollaborationServicewith full invite/acceptance lifecycle- Firehose indexing and XRPC endpoints for collaboration records
- Collaboration invite flow in the submission wizard
- Invitations inbox page for pending invites
DidAutocompleteInputcomponent for collaborator selection (replaces raw DID text input)
- Knowledge-graph-based Cosmik connection-type mapping
- Firehose plugin bridge for cross-AppView sync
syncEdgeToCosmikwired through all edge paths- Repair-mirror UI for fixing out-of-sync edges
- Enriched Cosmik card metadata (DOI, author, description, publishedDate, externalIds)
- Semble badge on relation-type autocomplete suggestions
pub.chive.*lexicons and XRPC handlers for follow count, follow status, and Margin annotations
- Canonical external-ID routes (
/doi/<id>,/arxiv/<id>,/orcid/<id>,/ror/<id>,/isbn/<id>,/pmid/<id>,/wikidata/<id>) - Zotero/Citoid-compatible server metadata endpoint
- OAuth client metadata no longer declares
transition:generic— the legacy scope short-circuited granular permissions and caused consent screens to display "any public record" instead of Chive's specific collections - OAuth login requests use individual
repo:scopes instead ofinclude:pub.chive.auth.fullAccessreferences, because bsky.social cannot resolve permission-set lexicons until the DNS TXT records at_lexicon.<sub>.chive.pubare live - User profile is now fetched from the public Bluesky AppView (
public.api.bsky.app) rather than through the authenticated session, so avatars and handles resolve correctly under granular scopes (session-boundagent.getProfile()returns 403 without an explicitrpc:app.bsky.actor.getProfilegrant) - Container log rotation enabled across all services (JSON logs capped at 150 MB per container) to prevent unbounded disk growth
- Cosmik dual-write edges now emit HTTP URLs and resolve connection types through the AppView
- Wikidata URLs for properties use the
Property:prefix - Collaborators column migration no longer trips on dollar-quoting
- Plugin DI dependencies registered in the indexer entry point so the plugin manager can resolve
ILogger - Deploy App workflow no longer wipes
chive-docsafter a concurrent Deploy Docs (new step restores the container fromdocker-compose.docs.ymlif a build exists) - React hook placement and
useCurrentUserdestructuring errors - Compliance test expected index/table counts updated for new collaboration tables
- Granular OAuth scopes limit Chive to writing only to its declared
pub.chive.*collections plus the explicit external cross-post targets, instead of the blanket write access granted bytransition:generic
0.5.1 - 2026-03-30
- Docs deploy workflow missing
environment: productionfor secret access - Rate limits too low for SPA usage patterns (Traefik raised to 2000 avg / 500 burst, backend authenticated raised to 1200 req/min)
0.5.0 - 2026-03-28
- Public landing page with inline ATProto login and open alpha notice
- Bug report button in site header and mobile nav with pre-filled GitHub issue URL
- Open alpha banner for authenticated users (dismissable, localStorage-persisted)
- Onboarding prompt banner for new users to link academic accounts (auto-dismisses when ORCID is linked)
- Permanent redirects from
/applyand/pendingto/
pub.chive.moderation.createReportXRPC endpoint for user-submitted content reportscontent_reportsdatabase table with migrationContentReportServicewith atomic upsert, pagination, and admin review methods- Report dialog on eprint detail pages with reason categories and description (2000 char limit)
- Lexicon schema for moderation createReport
- ORCID OAuth 2.0 authorization code flow for verifying researcher identity
pub.chive.author.initiateOrcidVerificationXRPC endpoint generating state and returning ORCID authorize URL/api/v1/auth/orcid/callbackREST handler for token exchangeorcid_verified_atcolumn onauthors_indexfor tracking verification statusorcidVerifiedboolean field inpub.chive.author.getProfileresponse- "Sign in with ORCID" button in onboarding wizard (replaces "Coming soon" placeholder)
- "Verify with ORCID" button in profile settings form
- Verified badge (ShieldCheck icon) on
OrcidBadgecomponent for OAuth-verified ORCIDs - Popup-based OAuth flow with localStorage event fallback for cross-origin communication
- Writes verified ORCID to user's PDS profile record after OAuth completion
- Graceful fallback to "Coming soon" when ORCID OAuth credentials are not configured
- ORCID credentials wired into staging and production deploy workflows
- All user-facing Bluesky references replaced with ATProto in login form, login dialog, and handle input
- Landing page restored to inline login style with ATProto handle input
- ConditionalHeader only hides on
/login(was also hiding/,/apply,/pending) - OAuth callback redirects to
/dashboarddirectly instead of/
- Alpha gate (
AlphaGatecomponent) removed from all 16 layout/page files - Alpha application system: frontend components, hooks, pages, admin pages, scripts, E2E tests
- Alpha XRPC handlers (
pub.chive.alpha.apply,pub.chive.alpha.checkStatus) - Alpha admin handlers and lexicon schemas
AlphaApplicationServiceandrequireAlphaTestermiddleware- Alpha type re-exports from
web/lib/api/schema.ts
- Open redirect vulnerability in login page redirect parameter
- localStorage SSR safety guards in banner components
- Stale alpha references in admin nav, coming-soon page, and OAuth callback
- Login redirect parameter validated to prevent open redirects (blocks
//evil.comandhttps://...) - AT-URI and NSID format validation on content report submissions
- Description length limit (2000 chars) enforced on frontend and backend for content reports
- ORCID client secret kept server-side only; state parameter is crypto-random, single-use, Redis-backed with TTL
0.4.1 - 2026-03-19
pub.chive.eprint.listCitationsreturned 500 when a citation carried structured author objects. Authors are formatted as strings, as the lexicon requires.
0.4.0 - 2026-03-18
- ATProto-standard
revisionfield on all lexicon files andschemaRevisionon record-type lexicons - Central
lexicons/manifest.jsonregistry tracking revision, project version, and change date for every lexicon lexicons/VERSIONING.mddocumenting the versioning strategy, changelog, and migration table
- Backend migration service (
src/services/migration/) that transforms old-format PDS records at index time - Migration 0001: convert abstract string to rich text array, add titleRich for LaTeX titles, add license URI from slug mapping (submission rev 1 to 2)
- Migration 0002: replace flat
departmentfield on affiliations with recursivechildrentree (submission rev 2 to 3, profile rev 1 to 2) - Migration chaining so records at any prior revision are brought up to current in a single pass
- Replace flat
departmentfield with recursive tree structure (childrenarray) supporting arbitrary institutional hierarchies (university, school, department, lab, etc.) - Define canonical
pub.chive.defs#affiliationshared type withname,institutionUri,rorId, andchildren - All lexicons now reference the shared affiliation type via cross-lexicon ref instead of local definitions
- Each level in the affiliation tree can independently link to a knowledge graph node via
institutionUri - Eprint cards display only the top-level institution name; full hierarchy shown in author detail views
- Alpha dashboard returning 500 because
affiliationsandresearchKeywordslexicon schemas defined items as strings but actual data contains objects
institutionUridropped from affiliations in eprint card, edit sections, submission wizard Zod schemas, and sub-unit editing- Profile record creator missing
institutionUriandchildrenfields, silently stripping tree structure on profile save - Eprint submission Zod schema missing
institutionUri, stripping institution graph links on submit
0.3.1 - 2026-03-11
- Eprint card abstracts not rendering when API returns plain text string instead of rich text array (eprints page, field pages)
- Browse page missing author avatars because
browseFacetedhandler did not fetch profiles from Bluesky API
0.3.0 - 2026-03-10
- Personalized authors page showing authors the user follows or has interacted with
- Mute author feature allowing users to hide papers from specific authors in feeds and discovery
pub.chive.actor.mutelexicon record type for storing mute preferences in user PDSes
- Configurable discovery weight settings allowing users to tune recommendation signal strengths (field affinity, citation overlap, recency, collaborative filtering)
- XRPC array parameter parsing for multi-value query parameters
- GHCR image registry for CI-built Docker images, eliminating on-server builds during staging deploys
- Staging docs container added to CI/CD pipeline
- Consolidated rich text
$typereferences to usepub.chive.richtext.defsnamespace across all tests, lexicons, and frontend code - Replaced
EprintsByAuthorResponsewithListByAuthorResponsefrom generated types inuse-eprint.ts - Replaced
EprintChangelogsResponsewithListChangelogsOutputfrom generated types inuse-eprint-mutations.ts - Replaced manual
Backlink,BacklinkCounts, andListBacklinksResponsewith generated types inuse-backlinks.ts - Added backlink and changelog type re-exports to
schema.tsfrom generated lexicon types - Replaced
EdgesResponseinuse-edges.tswith generatedOutputSchemafrompub.chive.graph.listEdges - Replaced
AuthorEprintsResponseinuse-author.tswithListByAuthorResponsefrom generated types - Derived
ProposalStatusandProposalTypefrom generatedProposalViewinstead of manual string unions - Derived
VoteActionfrom generatedVoteView['vote']instead of manual string union - Derived
AlphaSectorandAlphaCareerStagefrom generatedalpha/applyInputSchemainstead of manual string unions
- Comprehensive mobile responsiveness overhaul across the entire frontend
- Dashboard, admin, and governance sidebars collapse into Sheet drawers on mobile instead of stacking above content
- Mobile hamburger menu now includes dashboard navigation items for authenticated users
- Mobile search access via dedicated search icon button that opens a top Sheet
- Admin tables wrapped in horizontal scroll containers to prevent page overflow on narrow screens
- Grid layouts use progressive responsive breakpoints (
sm:grid-cols-2 md:grid-cols-3) instead of jumping directly to multi-column - Tab lists hide scrollbars for cleaner horizontal scrolling on mobile
- Popover widths constrained to viewport with
max-w-[calc(100vw-2rem)] - PDF viewer minimum height reduced on mobile (
min-h-[400px] md:min-h-[600px]) - New
useIsMobilehook and shadcn Sheet component for consistent mobile patterns
- Replace redundant annotation hook types (
AnnotationView,EntityLinkView,ListAnnotationsResponse,AnnotationThread) with generated lexicon types frompub.chive.annotation - Replace
ListUserEndorsementsResponsewith generatedOutputSchemafrompub.chive.endorsement.listForUser - Derive
AnnotationMotivationfrom generatedAnnotationView['motivation']instead of manual string union - Derive
ContributionTypefrom generatedEndorsementView['contributions'][number]instead of manual string union
- Related papers scoring: lowered combined score threshold from 0.2 to 0.05, reduced ES MLT discount from 0.6 to 0.85, and added author overlap to default signals
- Removed For You feed in favor of configurable discovery weights
- Enabled server-side XRPC output validation (
validateOutput: true) to catch schema mismatches at the source before responses reach clients
getHierarchyhandler missing defaultrelationSlugvalue, causing 500 when output validation is enabled- Authors page only showing search box instead of author grid due to incorrect conditional rendering
- Eprint integration tests failing due to stale PostgreSQL data from prior runs; added
beforeEachcleanup
- Field name mismatches between admin API responses and frontend expectations
- Role name references corrected from
moderatortoadminacross admin endpoints - Silent auth failures in admin routes now surface proper error messages
- Paper PDS auth wired through all edit and delete flows, fixing unauthorized errors when modifying papers
- Unified auth error messages across all endpoints for consistent error handling
- Removed broken "View all related papers" link pointing to nonexistent page
- Flaky mention-popover arrow key navigation test stabilized by replacing
fireEvent.keyDownwithuserEvent.keyboard
- Staging deploy workflow now builds web frontend image in CI and pins both API and web images to the exact commit SHA, preventing frontend-backend version skew
- Production deploy fixed to build web image with
docker buildinstead of silent no-opdocker compose build(compose file had nobuild:section for chive-web) - Split Docker build and push steps to fix GHCR authentication failure
- Test expectations updated to match unified auth error messages
- Discovery test expectations updated for new default weights and weight normalization
0.2.0 - 2026-03-06
- Admin dashboard with 15 pages: overview, health, alpha access, users, content, firehose, backfill, PDS, graph, metrics, search analytics, activity, endpoints, runtime, and governance
- AdminService for aggregating system health, content statistics, and user management operations
- BackfillManager for triggering and monitoring PDS record backfills from the admin UI
- XRPC handlers for all admin dashboard endpoints across health, content, users, firehose, PDS, graph, metrics, search, activity, and governance
- Frontend admin auth guard component that restricts dashboard access to users with admin roles
- Role-based access hooks (
useMyRoles,useIsAdmin) and admin dashboard query hooks - Admin role seeding from
ADMIN_DIDSenvironment variable on server startup pub.chive.actor.getMyRolesXRPC endpoint for querying the authenticated user's roles- Direct alpha access grant dialog on the admin alpha management page
- Lexicon schemas for admin and actor role endpoints
- Permission set lexicon schemas (
basicReader,authorAccess,reviewerAccess,fullAccess) following the ATProtopermission-setLexicon type - Hierarchical permission model: basicReader (read-only RPC) < authorAccess (eprint/profile writes + claiming + blobs) < reviewerAccess (reviews + annotations) < fullAccess (graph governance + proposals)
- Scope constants for all 19
pub.chive.*repo collections, 6 external namespace collections (Bluesky, Standard, Cosmik), and 5 blob MIME type wildcards buildScopeStringutility for constructing space-separated OAuth scope strings with automaticatprotoprefix and deduplication- Intent-based login flow with
AuthIntenttype (browse,submit,review,full) that requests only the scopes needed for each activity - Frontend
getScopesForIntentandhasScopeutilities withtransition:genericbackward compatibility CLIENT_METADATA_SCOPEconstant combining full access permission set with all external namespace scopes- OAuth client metadata updated to declare granular scopes alongside
transition:genericfor backward compatibility with PDSes that don't support granular scopes
- Docker smoke tests and staging deployment workflow with environment branch strategy
NEXT_PUBLIC_CHIVE_SERVICE_DIDbuild arg for per-environment service DID configuration
- Prometheus metric groups for jobs, workers, auth, search, blob proxy, dead letter queue, admin, and backfill operations
- OpenTelemetry span instrumentation for auth verification, background jobs, worker processing, and blob proxy requests
- Faro error boundary around eprint detail page for rendering crash diagnostics with trace ID references
- Field label resolution job and indexer retry for unresolved UUID field labels
makeJobIdutility for sanitizing AT URIs into valid BullMQ job identifiers
- Admin dashboard documentation covering API endpoints, backfill operations, observability metrics, architecture, and role management
- OAuth scopes documentation covering permission set definitions, intent-based login, and backward compatibility
- Documentation suite overhaul with formatting improvements, accuracy corrections, and staging deploy workflow
- Unit tests for AdminService, BackfillManager, admin XRPC handlers, admin seed script, and observability Prometheus instrumentation
- Frontend unit tests for admin hooks, admin auth guard, and role hooks
- Backend and frontend unit tests for OAuth scope constants, permission sets,
buildScopeString,getScopesForIntent, andhasScope
- Collection visibility renamed from
public/privatetolisted/unlistedacross lexicon, backend, frontend, and tests to reflect ATProto semantics (visibility controls AppView listing, not data access) - PostgreSQL migration to rename existing visibility column values with backward-compatible normalization for old values
- GitHub Actions CI workflows updated with
ADMIN_DIDSenvironment variable, admin health check endpoints, and expanded Prometheus metrics collection targets - Documentation accuracy: removed non-existent content moderation features, corrected role names, fixed contact emails, replaced Semble references with Cosmik
- OAuth scope requests fall back to
transition:genericuntil PDSes support granular permission sets - Grafana Alloy Faro log pipeline switched from JSON stage to regex stages for Faro's key=value log format
- React 19 + Radix UI infinite loop crash (error #185) when selecting endorsement types by replacing Radix Checkbox with native HTML input
- BullMQ job ID validation errors caused by colons in AT URIs by sanitizing job IDs in enrichment, freshness, and index-retry workers
- AlphaGate redirecting approved users to login on transient auth refetch failures
- Null reference crashes on eprint detail page from missing
abstract, endorser display names, or contribution arrays - ~90 incorrect Wikidata Q-IDs across governance seed data
- Invalid lexicon schemas for citation record and listCitations query
- Permission set scopes to cover all frontend writes including external namespaces and blob types
- Docker smoke test to use
/readyendpoint instead of nonexistent/xrpc/_health - Backend
SERVICE_DIDenvironment variable name to match deploy configuration - Reindex script to use MERGE instead of CREATE to handle Neo4j uniqueness constraints
- Staging deploy to pull from
origin/staginginstead oforigin/main - Admin role reference from
moderatortoadminin getPendingClaims endpoint - Alpha applications table sync when granting access via admin role assignment
- Permission-set lexicons excluded from codegen to prevent build errors
- Neo4j Cypher syntax error in recommendation queries that prevented related papers from loading (moved
UNION ALLinsideCALL {}blocks for Neo4j 5.x compatibility)
0.1.0 - 2026-03-03
Initial release of Chive, a decentralized eprint service built on AT Protocol.
- ATProto-native AppView that indexes scholarly records from the relay firehose without writing to user PDSes
- Hono-based API framework serving both XRPC and REST endpoints (~130 XRPC endpoints across 19 service areas)
- Modular service architecture with all services behind abstract
I*interfaces for dependency injection - Multi-database storage layer: PostgreSQL (metadata indexes), Elasticsearch (full-text search), Neo4j (knowledge graph), Redis (caching and rate limiting)
- All database tables use
_indexsuffix and track PDS source URLs for staleness detection and rebuild-from-firehose capability - Plugin system with hybrid TSyringe (DI) and EventEmitter2 (hooks) architecture, isolated-vm sandboxing, and declared permissions
- Background worker system with enrichment, freshness, and index-retry workers
- Scheduled jobs for citation extraction, field promotion, governance sync, graph algorithms, PDS scanning, and tag sync
- OAuth-based authentication with DID resolution, session management, and ATProto identity verification
- Rate limiting with configurable fail-open/fail-closed behavior when Redis is unavailable
- Firehose consumer with WebSocket connection to ATProto relay, filtering for all
pub.chive.*collections - Support for both full firehose events (CAR/CBOR parsing) and Jetstream events (pre-decoded JSON)
- Cursor management with batched persistence to PostgreSQL for resumption after restarts
- Dead letter queue for failed events with error classification (transient, permanent, rate limit) and exponential backoff retry
- Backpressure handling to prevent memory exhaustion when queue depth exceeds threshold
- Reconnection manager with exponential backoff and jitter for network failure recovery
- PDS Discovery system with three discovery sources: PLC directory enumeration, relay listHosts queries, and DID mention extraction
- PDS Scanner that backfills records from all 19
pub.chive.*collections on discovered PDSes - PDS registry with scan priority scheduling (24h for active, 7d for inactive) and consecutive failure tracking
- User-facing
pub.chive.sync.registerPDSendpoint for self-hosted PDS registration - Graceful shutdown with queue draining and cursor flushing
- 164 lexicon JSON schemas across 21 namespaces under the
pub.chive.*namespace - Record types for eprints (
submission,version,changelog,tag,userTag,citation,relatedWork) - Record types for reviews (
comment,endorsement,entityLink) - Record types for annotations (
comment,entityLink) - Record types for the knowledge graph (
node,edge,nodeProposal,edgeProposal,vote) - Record types for actor profiles (
profile,profileConfig) - Query and procedure schemas for collections, discovery, governance, claiming, metrics, activity, tags, backlinks, sync, notifications, and import
- Lexicon code generation pipeline producing TypeScript types and runtime
isRecordtype guards - Rich text facet definitions for cross-references
- Eprint submission with structured metadata: title, abstract, keywords, authors with affiliations and contribution types, publication tracking
- Eprint versioning with version number tracking, previous version references, and change descriptions
- Eprint changelog tracking for edit history
- LaTeX-to-Unicode abstract migration with
needsAbstractMigrationschema hint detection - License metadata with URI-based license identification
- PDF blob reference storage (CID pointers only, never blob data) with on-demand PDS fetching
- Eprint soft deletion support
- OG image generation for social media sharing
- Full-text search powered by Elasticsearch with KStem stemmer,
bool_prefixqueries, and field-specific boosting - Faceted search filtering by field, author, date range, keywords, and publication status
- Search autocomplete with search-as-you-type suggestions
- Personalized discovery dashboard with multi-signal scoring (field affinity, citation overlap, recency, collaborative filtering)
- "For You" feed with personalized paper recommendations
- Similar papers with "More Like This" fallback when primary signals are insufficient
- Field-filtered trending eprints
- Citation-based paper discovery
- Dismiss flow for unwanted suggestions with
recordInteractiontracking - Elasticsearch index lifecycle management policies, ingest pipelines, and index templates
- Community-governed taxonomy using SKOS/FAST faceted classification stored in Neo4j
- Node types: fields, methods, datasets, tools, platforms, licenses, and custom personal nodes
- Edge types with semantic relation labels (broader, narrower, related, applied-to, uses, etc.)
- Bidirectional edge support with configurable directionality
- Graph hierarchy browsing with faceted navigation
- Subgraph expansion for exploring node neighborhoods
- Community detection, PageRank, and centrality graph algorithms
- Wikidata integration via SPARQL for external identifier enrichment
- Node autocomplete with search-as-you-type across all node types
- Node proposals and community voting for Wikipedia-style moderation
- Governance PDS (
did:plc:chive-governance) for storing community-approved authority records as ATProto-native portable data - Governance sync job for periodic authority record synchronization
- Trusted editor elevation requests with approval/rejection workflow
- Role delegation system for governance authority distribution
- User-owned collections stored as personal graph nodes in user PDSes
- Collection wizard with multi-step creation flow (basics, items, edges, structure, cosmik integration, review)
- Subcollection nesting with
SUBCOLLECTION_OFedges and automatic parent propagation on add - Add-to-collection buttons on eprint, review, endorsement, and graph node cards throughout the UI
- Inter-item edge editing for creating relationships between items within a collection
- Collection activity feeds tracking changes to watched items
- Collection search, public listing, and owner listing
- Hierarchical collection dashboard with depth-based indentation and expand/collapse
- Direct-only vs all-items view toggle for subcollection content
- Delete propagation from subcollections to parent collections
- Drag-and-drop item reordering
- Cosmik dual-write integration for cross-platform collection mirroring
- Nine XRPC endpoints for collection management
- Inline review comments with threaded discussion support
- Formal endorsement records with contribution type classification (methodology, results, novelty, clarity, significance, overall)
- Endorsement summary aggregation per eprint
- Entity linking from review text spans to knowledge graph nodes
- Review listing by eprint and by author
- Thread context retrieval for navigating discussion hierarchies
- Notifications for new reviews and endorsements on authored papers
- Inline text annotations on eprint PDFs using W3C Web Annotation data model
- Text selection anchoring with highlight persistence
- Entity link annotations connecting selected text spans to knowledge graph entities
- Annotation sidebar with navigation and deletion
- Dedicated annotation lexicon schemas separated from review system
- Annotation listing by eprint, by page, and by author
- Citation extraction pipeline with GROBID integration for parsing reference sections from PDFs
- Extracted citation indexing in PostgreSQL with eprint cross-referencing
- User-curated related works linking between eprints
- Related papers panel with unified display of extracted citations and curated links
- Citation-based discovery signals feeding into recommendation engine
- Actor profile records with display name, bio, ORCID identifier, and institutional affiliations
- Profile configuration with customizable display sections
- Featured collection display on author profile pages
- Author claiming workflow with coauthor verification for papers imported from external sources
- Coauthorship request and approval/rejection flow
- Autocomplete for ORCID, affiliations, keywords, and OpenReview profiles
- External paper claiming from arXiv, OpenReview, and other integrated sources
- Claimable paper suggestions with dismiss capability
- Author-assigned tags on eprint submission
- User-contributed tags (folksonomy) with quality scoring
- Tag trending with time-decay algorithms
- Tag search and autocomplete
- Tag detail pages showing tagged eprints
- Tag suggestions based on eprint content
- Tag sync job for periodic data consistency
- Cross-platform backlink aggregation from Bluesky, Cosmik, Whitewind, and Leaflet
- Backlink creation and deletion endpoints
- Backlink count aggregation per eprint
- Cosmik backlink source type support
- View, download, and dwell time recording for eprints
- Search click and search download tracking
- Trending calculation based on engagement metrics
- Activity feed logging with correlation metrics
- Failed activity tracking for monitoring
- arXiv plugin with paper search and metadata retrieval
- OpenReview plugin for conference paper search
- PsyArXiv plugin for psychology preprint search via OSF API
- LingBuzz plugin for linguistics preprint scraping with respectful rate limiting
- Semantics Archive plugin for semantic web publication scraping
- Plugin import scheduling with configurable intervals and run-on-start support
- Bluesky, Cosmik, Whitewind, and Leaflet backlink plugins
- Plugin framework with lifecycle management, hook system, and sandboxed execution
- Next.js 15 application with React 19 and App Router
- 68 page routes covering search, eprints, authors, collections, governance, dashboard, submission, discovery, and authentication
- 350+ React components organized by domain
- TanStack Query data fetching with optimistic updates and cache management
- TipTap rich text editor with cross-reference
[[autocomplete for knowledge graph entities - PDF viewer with text selection, highlight anchoring, and annotation overlay
- Responsive design with mobile-friendly tab scrolling
- Radix UI component library with Tailwind CSS styling
- Grafana Faro frontend observability with configurable trace and session sampling
- OpenTelemetry instrumentation for distributed tracing across all services
- Grafana Alloy agent for receiving frontend Faro and backend OTLP telemetry
- Tempo for distributed trace storage and querying
- Loki for centralized log aggregation
- Prometheus metrics collection with custom Chive metrics (indexing events, queue depth, lag, PDS scan durations)
- Grafana dashboards for visualization
- Structured JSON logging with configurable log levels
- Health check endpoints (
/health,/readiness) with detailed service status
- Multi-stage Docker build with separate
productionanddevelopmenttargets - Docker Compose configurations for production, local development, observability, and documentation
- Traefik reverse proxy with automatic Let's Encrypt SSL certificate management
- Governance PDS container running Bluesky PDS image for authority record storage
- GROBID container for PDF citation extraction with tuned memory limits and health check timing
- Kubernetes manifests with Helm charts, horizontal pod autoscaling, pod disruption budgets, and RBAC
- CI pipeline with 7 jobs: unit tests, type checking, lint and format, ATProto compliance, integration tests, build, and deploy
- Automated deployment via GitHub Actions with environment file generation, Docker image building, and post-deploy verification
- Build cache cleanup to prevent disk space accumulation from
--no-cacheDocker builds - Separate indexer process for resource isolation from the API server
- Docusaurus documentation site with auto-generated interactive API docs from OpenAPI specification
- User guide covering searching, submitting eprints, editing, peer review, endorsements, profiles, authorship claiming, tags, collections, discovery, and Bluesky sharing
- Developer guide covering API layer, authentication, core services, lexicon validation, frontend architecture, rich text, eprint lifecycle, and observability
- Service documentation for indexing, collections, discovery, claiming, and PDS discovery
- Plugin documentation with creation guide and built-in plugin reference
- Storage documentation for PostgreSQL, Elasticsearch, Neo4j, and Redis
- Architecture overview, concepts (AT Protocol, knowledge graph, data sovereignty), governance, operations, and reference documentation
- ATProto compliance test suite with 100% pass rate requirement validating data sovereignty principles
- End-to-end test suite with 42 Playwright test files covering all major user workflows
- Integration test suite with 32 test files covering API endpoints, services, and storage layers
- Unit test suite with 134 test files covering handlers, services, storage adapters, plugins, and utilities
- Test infrastructure with Docker test stack, seed data scripts, and cleanup utilities