-
-
Notifications
You must be signed in to change notification settings - Fork 28
CardDAV Write Back Internals
How FreeITSM sends a change made here back to a contact card, without damaging the card. Asked for in #133, where the argument for it is that a phone number changes during the call and correcting it again in a second application is exactly the double entry a service desk tool ought to remove.
This page covers the write half only. Reading β connecting, PROPFIND, REPORT, and turning a vCard into a person β is on the developer guide; the import lifecycle is on CardDAV import internals; the administrator's view is on CardDAV contact sync.
includes/carddav_write.php exists so that a sentence stays true:
The import issues
PROPFINDandREPORTand nothing else.
That is the honest answer when an operator asks whether connecting FreeITSM can damage their address book, and it is worth more as a fact about the code than as a promise about intentions. PUT lives in a file the import path never loads. Nothing about switching write-back on changes what an import does.
The page is separate for the same reason the file is: reading a vCard is a small idea, and editing one safely is not.
π΄ Never generate a vCard. Edit the bytes the server sent.
The obvious implementation takes the person fields, renders a card, and PUTs it. That silently destroys everything FreeITSM does not model:
| On the card | FreeITSM models it? |
|---|---|
PHOTO |
no |
BDAY |
no |
NOTE |
no |
ADR street, region, postcode, country |
only the town |
X-* properties written by the operator's CRM |
no |
CATEGORIES |
read for scope, never stored |
item1.X-ABLabel and other Apple group labels |
no |
A regenerating implementation looks like it worked. The damage is discovered months later, and there is nothing on our side to restore from.
So cardDavApplyPersonEdits() rewrites only the line whose value changed and passes every other line through untouched, understood or not.
π This also disposes of the vCard 3.0 versus 4.0 question, which otherwise needs a policy nobody wants to own. The card keeps whatever VERSION it already declares, because nothing rewrites it.
| Function | Job |
|---|---|
cardDavLineSpans() |
split into logical lines remembering byte offsets, the counterpart to cardDavUnfold() which throws them away |
cardDavLineProperty() |
the property name of a line, group prefix stripped |
cardDavReplaceLineValue() |
swap a value, keeping the name, the group prefix and every parameter |
cardDavInsertLine() |
add a property before END:VCARD
|
cardDavRemoveLine() |
delete a line and its continuations |
cardDavSetComponent() |
change one field inside a semicolon-separated value |
cardDavFoldLine() |
fold at 75 octets, UTF-8 safe |
cardDavEscapeText() / cardDavUnescapeText()
|
the vCard TEXT escaping pair (these two live in carddav.php, because reading needs them too) |
cardDavApplyPersonEdits() |
the whole surgical edit, field by field |
cardDavGetCard() / cardDavPutCard()
|
one card in, one card out |
cardDavCanWrite() |
ask the server what this account may do |
cardDavPushPersonChanges() |
the orchestrator, including the conflict rule |
Exactly five, matching USER_CARDDAV_OWNED in includes/users.php:
| Field | Where it lives on the card |
|---|---|
job_title |
TITLE, the whole value |
phone |
TEL without a CELL type |
mobile |
TEL;TYPE=CELL |
department |
ORG, component 1 of 2
|
office |
ADR, component 3 of 7 (the locality) |
employee_id and manager_id are never sent. A vCard has nowhere to keep a payroll number, and almost nothing in the wild writes RELATED;TYPE=manager.
β οΈ This is also why they became editable. Before this work,USER_DIRECTORY_OWNEDgreyed out all seven person fields on any managed record β so an imported contact had their employee ID and manager locked against edits on behalf of an import that would never populate them.userDirectoryOwnedFields($protocol)now answers per protocol: LDAP and OIDC keep all seven, CardDAV owns five.
ORG:Acme Ltd;Finance and ADR:;;12 High St;Norwich;;NR1 1AA;UK each hold several fields in one line. Writing the whole value back would erase the company name and the entire street address respectively.
π An emptied component is blanked in place, never removed. Deleting the ADR line because somebody cleared the office would take the postcode with it. A whole-value field like TITLE is removed when cleared, because there is nothing else on that line.
cardDavSetComponent() pads with empty components when the stored value is shorter than the index being written. A card carrying only ADR:;;12 High St has no town slot at all, and appending one needs the separators in between or the town lands in street.
A card routinely carries three or four TEL lines. "The mobile" is not a property name β it is the result of a selection rule: first CELL, else first WORK, else the first untyped one.
If the writer re-implemented that rule and got it even slightly different, editing somebody's mobile would overwrite their desk number and leave the mobile untouched, with no error anywhere.
So cdsyncPhoneLines() was split out of cdsyncPhones() and returns line indexes. The reader takes the values at those indexes; cardDavLocateField() writes to the same ones. One selector, used by both ends, makes the mismatch unrepresentable rather than merely unlikely.
RFC 6350 Β§3.4: inside a text value, \\, \,, \; and \n stand for a backslash, comma, semicolon and line break.
π΄ A single left-to-right pass, not chained
str_replacecalls.
Chaining gets \\, wrong in both directions. Unescaping \\ first leaves \,, which the next pass then reads as an escaped comma β so a value that legitimately ended in a backslash before a comma comes out having silently lost the separator.
cdsyncOrg() and cdsyncLocality() handled \; and \,; TITLE and TEL did not, so a job title of Head\, Sales imported with the backslash in it. Harmless while the sync was one-way β and compounding the moment a value can be written back, since it would be re-escaped to Head\\, Sales and the card would gain a backslash on every run.
The property that matters is not "escapes correctly once" but round-trips without drift: a value read and written five times must come back identical. That is what the tests assert.
The 75-character limit is defined in octets, not characters. A naive substr($s, 0, 75) splits a multi-byte character across the fold, and the two halves either side rejoin into a byte sequence that is not valid UTF-8.
This is not an edge case for FreeITSM: there are real users running Polish and Danish installs, and ordinary names and job titles hit it.
cardDavFoldLine() walks back off a continuation byte (10xxxxxx), at most three bytes, so a character is never cut in two. Continuations carry a leading space, which counts toward the 75.
β οΈ Testing this needs a case that would actually have broken. A test that folds a long string and checks the result is valid UTF-8 passes trivially when the boundary happens to land cleanly. The suite searches for paddings where the naive cut would have split the character, asserts it found at least one, and only then checks the guard β for 2-, 3- and 4-byte characters.
Three schemes are plausible and two are wrong.
| Scheme | Verdict |
|---|---|
| PUT with the ETag stored at import time | Safe but unusable. Refuses whenever any field on the card changed since the last sync. Correcting a phone number is blocked because somebody added a birthday last week. |
| GET the card, edit it, PUT with the ETag that GET just returned |
Never refuses anything. This is not convenience β it is the concurrency guard switched off, because If-Match can no longer fail: we just asked the server which value would make it pass. |
| What FreeITSM does | GET fresh; compare the server's current value of each field being written against what FreeITSM held before the save; refuse only on a same-field difference; apply the edit to the fresh bytes; PUT with the fresh ETag. |
The third is the only one that both preserves other people's work and notices when two people changed the same thing.
- Somebody else edited a different property β their change survives, ours goes through.
- Somebody else edited the same detail β refused, and the operator is told what their version says.
- Somebody else edits between our GET and our PUT β
If-Matchcatches it, 412.
π A 412 is the feature, not a failure. It means somebody got there first. The right next step is to import again and look at their version, which is what the message says.
cardDavPutCard() refuses an empty ETag rather than sending If-Match: *, which means "any existing card" and would be exactly the unguarded overwrite this exists to prevent. No stored ETag means we have not really read this card.
api/tickets/save_user.php reads the five person fields before the UPDATE, not after β comparing the new value with itself would make every write look uncontested.
There is no roster of "CardDAV servers FreeITSM can write to", and there should not be. Writing is a plain PUT from RFC 6352, the same standard as the reads, so any server the import works against can in principle be written to.
What genuinely varies is permission. A shared, subscribed or published address book is very commonly read-only however correct the password is.
cardDavCanWrite() issues a PROPFIND for current-user-privilege-set and looks for all, write or write-content.
π΄ It writes nothing. Testing by creating a card and deleting it again is worse than it sounds: the delete can fail, and the operator is left with a contact called "FreeITSM test" in their real address book.
user_sso_identities gained two columns:
| Column | Meaning |
|---|---|
source_ref |
where the record lives in the source β an LDAP distinguished name, or the URL of the individual .vcf
|
source_etag |
the source's version marker (a CardDAV ETag). NULL for LDAP and OIDC |
Named for the job rather than the protocol, because subject on that table already means two different things depending on the provider.
Without source_ref, changing one contact's phone number would mean re-fetching the entire address book to find their card β slow, and a race against anybody editing in between.
π΄ Both are refreshed on every sighting, not just on first link. A card that moves between address books gets a new URL, and its ETag changes whenever anybody edits it. A stale pair is worse than none: the href would write to a card that has moved, and the ETag would make every write look like a conflict.
The push happens after FreeITSM's own record is saved, and cannot undo it.
An address book that is unreachable, read-only, or holding a newer value is a fact to report β not a reason to throw away the edit somebody just made, about a problem they cannot do anything about. Every outcome is returned; none of them is an exception.
save_user.php adds an address_book block to its response only when a write was actually attempted, so an ordinary save carries no new keys and nothing downstream has to learn about a feature it does not use.
A bad import damages our data, and can be re-imported. A bad write-back damages the operator's address book, and we cannot undo it at all.
That is why write-back is deliberately narrower than import rather than its mirror: only fields FreeITSM owns, only on cards it imported, only when a human deliberately edited one.
There is no bulk reconciliation pass, and there should never be one. "Put the whole address book right" is the feature request that turns a careful integration into a data-loss incident.
π This section is the why. For how to actually diagnose a broken write-back β what each result means, how to read the log, both tools rung by rung, and a symptom-to-cause table β see CardDAV write-back β when it goes wrong.
Three things, and the first two were missing from the first cut of this feature.
save_user.php returns an address_book block, and both people editors render it β Tickets and Assets. That second half was briefly missing and it mattered: the API reported the failure perfectly and no screen read it, so an analyst on an unreachable server saw "Saved" and had no way to learn the card had not been updated. A silent failure, in the feature built end to end to avoid them.
A write-back runs inside a save. CARDDAV_INTERACTIVE_TIMEOUT (8s, 4s to connect) applies only on the push path, because the import's 20 seconds is fine for something you started and are watching, and unbearable when you have pressed Save and the page has gone quiet. Measured against a closed port: 2 seconds.
carddav_write_log, one row per attempt, surfaced under Changes sent back on the History tab.
| Outcome | Means |
|---|---|
ok |
written to the card |
conflict |
refused on purpose β somebody had changed the same detail |
failed |
the server could not be reached, or said no |
skipped |
nothing needed sending |
π The server's own response is stored verbatim, truncated rather than summarised. When an address book refuses something the reason is in its error body, and a paraphrase is useless for diagnosing a server this install cannot log into.
π conflict is coloured as a warning, not a danger. Refusing to overwrite a newer value is the safety net working. Red teaches an operator to treat it as a fault to clear, which is the opposite of what should happen.
delete_sso_provider.php, rather than left to the foreign key. database/freeitsm.sql declares ON DELETE CASCADE, but Database Verify creates tables from a column list and adds no constraints β so on an install where the table arrived via a verify there is no cascade at all. Confirmed rather than assumed: directory_sync_entries has the same 0 foreign keys, so this is a general property of verified tables.
Both under System β Debug Tools, and they answer genuinely different questions.
Walks the connection one rung at a time: reach β sign in β read the book β may this account write. Reported separately, because they fail for different reasons and a single verdict leaves four things to check.
It names the mismatch that actually bites: write-back on, no write privilege β every change saved locally and every one refused. It also reports how many people are missing source_ref, which is exactly what a pre-write-back import leaves behind.
π΄ Writes nothing. The permission question is the current-user-privilege-set PROPFIND, not a create-and-delete probe β that can fail halfway and leave a contact called "FreeITSM test" in a real address book.
The top-to-bottom compare: every imported person against their card, field by field, both values shown. Plus cards that have vanished, and contacts on the server that never came here.
β It answers the question the reporter never did. The #133 reply asked which details actually drift and in which direction, because that is the evidence that would shape two-way sync. No answer came. D016 measures it on the operator's own data and summarises which fields drift most.
π΄ No "fix it" button, deliberately. Which side is right is a judgement, and one button reconciling hundreds of contacts is the data-loss incident this whole design avoids.
docker/carddav-test/ β BaΓ―kal 0.12.1 on port 8092. See its README for the seed script and credentials.
What the live run against it proved, and what to re-prove after any change here:
-
cardDavCanWrite()returns real privileges. - A
PUTreturns 204 and a new ETag, which is then stored. - Reading the card back shows exactly one logical line changed out of the card's total β this is the assertion that matters most, and it is the one a regenerating implementation fails.
- A deliberately stale ETag returns 412 and is reported as a conflict.
- A same-field change made behind FreeITSM's back is caught by the three-way merge, with the other person's value in the message.
- With write-back off, nothing is attempted at all.
-
employee_idsent through the push does not appear on the card.
β οΈ Restore the card afterwards. The rig is scratch, but a test that leaves the fixture changed makes the next run's "before" wrong.
api/self-service/update_profile.php uses the same cardDavPushPersonChanges(), with $fromPortal = true, when System β Portal profile allows address-book contacts to change their details (and the book has write-back on). Two differences, both deliberate:
- π΄ It pushes FIRST and saves only on success. Β§11 says a failed write-back never loses the analyst's work, because the analyst is told and can act. A customer cannot, and a local value the book refused is exactly what the next import reverts - the vanishing edit this whole design exists to prevent. So a refusal (
attemptedfalse, orokfalse) returns an error and nothing is saved on either side. -
The log says who. A written row ends "Changed by the person themselves, in the self-service portal", and a conflict row says "the person set, in the self-service portal," instead of "the analyst set".
triggered_by_analyst_idis NULL.
Only the fields whose value actually changed are passed, so a save that only touched the preferred name sends nothing and logs nothing. Details and tests: Contact details β Developer Guide Β§3.
includes/carddav_create.php - Add to address book on an unlinked person (api/tickets/address_book_add.php, the button from assets/js/address-book-add.js on both people screens). Only for providers with carddav_write_back = 1 and carddav_allow_create = 1; the provider save forces the second to 0 whenever the first ends up 0, including when write-back was kept from the stored row.
π This file generates a vCard, and Β§2 still stands. The rule is about EXISTING cards, where rendering one from FreeITSM's fields destroys what FreeITSM does not model. A new card has nothing to destroy. The one existing card this code touches - the group card - is still edited byte-wise: one
MEMBER:urn:uuid:<uid>line inserted beforeEND:VCARD,If-Match, one re-read on 412. It is a separate file so the import path, which must stay provably read-only, never loads it.
cardDavCreateTargets(PDO $conn, array $user): array // books this person could go to, and why not
cardDavCreatePreview(PDO $conn, array $user, array $provider): array
cardDavBuildCard(string $uid, array $preview, ?string $organisation): string // vCard 3.0
cardDavCreateContact(PDO $conn, int $userId, int $providerId, ?int $analystId): arrayThe order, and why:
-
Read the book. Refuse (
duplicate, loggedskipped) if any non-group card has the person's email address - the import would adopt that card instead. For a group scope, find the group card with the import's either-or match (UID orFN); refuse (out_of_scope) if there is none. -
Create
<book>/<uuid>.vcfwithIf-None-Match: *. UID is a v4 UUID.ORG:<company>;<department>andADR;TYPE=WORK:;;;<office>;;;are written in the componentscdsyncOrg()/cdsyncLocality()read. A tag-scoped source getsCATEGORIES:<first chosen tag>. -
Group scope: add the
MEMBERline to the group card. On failure,DELETEthe new card. - π΄ Ask the import. Read the book again and run the import's own
cdsyncResolveScope()βcdsyncMapCard()βcdsyncInScope()on the new card. Not a copy of the rule - the rule. If it would not be imported, remove theMEMBERline again,DELETEthe card, link nobody, returnout_of_scope. Without this, a card outside the source's scope is invisible to the next import, which counts the person as missing and, aftersync_deactivate_afterruns, marks them as left. -
Link the existing record -
is_managed = 1,auth_provider_id,sync_missed_count = 0- anddsyncLinkIdentity()with the new UID as subject and the card's href and ETag as read back in step 4. The next import finds the person by UID (dsyncFindExisting()'s first rung), so it neither creates nor adopts. - Log
created(pill Added) with the fields written.
Also refused: a person already linked to a provider that still exists, a person with no email address, and a provider owned by a different company from the person's.
cdsyncResolveScope(), which unescapes only \,. The rollback test uses exactly that to force step 4 to fail; the import side is not fixed.
carddav_allow_create. The provider list must not select it (it once did, and System β Authentication showed No providers yet), and the save drops it - including from the keep-stored step - until it exists.
Tests (Docker clean room against BaΓ―kal, 51 checks): the provider switch rules; what is offered and to whom; an add under "everyone", with an import afterwards that creates and adopts nobody; duplicate email refused with nothing written; tag scope with a control that an untagged card really is missed by a tag-only import (brake off, sync_missed_count preset to 2 so a real sighting must reset it); group scope, existing members untouched; an unfindable group refused before writing; a real post-write rollback; switched off, including a direct request.
- CardDAV contact sync β the administrator's view, including the write-back setting and adding people
- CardDAV write-back β when it goes wrong β the operator's half: results, the log, D015 and D016
- CardDAV contact sync β developer guide β the transport and the mapping
- CardDAV import internals β scope, the run, and the test rig
- Contact details β Developer Guide β the portal writer and the Source filter
- CalDAV and CardDAV β the original analysis; both halves are now built
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96