Repository navigation
REST Inventory and Evidence PR Review
β Done - all three merged for 3.12.0 on 20 October 2026. Each PR went in as its own commit under @Dr4inder's name, followed by a review commit of mine. How the finished features work: custom asset fields, change evidence, inventory reports, and the design with its as-built notes. This page is the record of the review.
Written for @Dr4inder, who built all three, and for anyone - or any AI coding assistant - contributing to FreeITSM next. Each finding has the same shape: your code does X, the problem is Y, the approach now is Z, then a verdict: keep, change or leave out. The lessons that keep coming back across reviews are collected in Contributing.
| PR | What | Verdict | Commits |
|---|---|---|---|
| #195 | Custom asset fields on POST / PATCH /assets
|
Merge with fixes (small) | f0e17d3 + 66f6cc5 |
| #196 | Evidence attachments on a change | Merge with fixes (small) | 370a6a3 + 31221a9 |
| #197 | Source-aware inventory import | Merge with fixes (substantial rework) | 506b8d6 + e4ba975 |
All three were good pull requests, and much better than average. They came with the design already agreed, each part small enough to review on its own, and with tests. Things that should stay exactly as they are, and are worth copying:
-
One write path. #195 sends custom fields through
AssetFieldsService, the path the UI and the spreadsheet importer use. #196 moved the upload out of the UI endpoint intoChangesService::uploadAttachment(), and the UI now calls it too. Nothing was written twice. - Atomic, and honest about it. Core and custom fields commit together (#195), and a failed upload leaves no file behind and no row (#196). A whole report or nothing (#197). The transaction handling is careful: each service owns its transaction and refuses to run inside someone else's.
- Authorisation before anything is written. #196 refuses a read-only analyst and an unknown change before the file reaches the disk.
-
Narrow permissions.
change_attachmentsandasset_inventory.createlet an integration do one thing. An evidence key cannot edit, approve or delete a change. - Retries that check content. Reusing an upload reference with a different file is a 409 (#196), and so is a different section at the same observation time (#197). A retry is never mistaken for a match.
-
Path traversal handled properly. The download resolves
realpath()and checks the result is inside the attachments folder, and the test proves it by pointing a row at../../config.php. - The latent software collision found and fixed. The table's unique key (device, application) could not hold the same application from two sources, despite the code assuming it could. #197 widens it to (device, application, source) with an upgrade that keeps every row - 40,146 on the install it was tested on.
- Every test file is guarded (CLI only, then 404), so nothing can be run from a browser.
| # | Finding | Verdict |
|---|---|---|
| 1 | Unknown top-level keys still answered 200 | Change |
| 2 | The warranty calendar sync ran inside the transaction | Change |
| 3 | No positive test for a type change | Change |
| 4 | The test cleaned up by hostname | Change |
1. Unknown keys. Your code validates everything inside fields, as agreed. The problem: the agreed answer to decision 1 was also to name the top-level keys a write does not use, and those still came back as a plain 200, so a typo looked like a saved change. Now: AssetsService::ignoredKeys() lists them and the response carries "ignored": ["colour"]. They are still accepted, so older scripts keep working.
2. Warranty sync. Your code moved syncWarranty() inside the update's transaction. The problem: it re-syncs the warranty calendar for the whole estate and swallows its own errors. Inside the transaction it holds the asset's locks for all of that, and a half-done sync commits with the asset. Now: it runs after the commit, as createAsset() already did.
3. Type change. The tests proved that a field of the wrong type is refused. They did not prove the case the design promised: changing the type and setting a field of the new type in the same request. Now: there is a test for exactly that, plus a check that the old type's field is then refused.
4. Cleanup. The test deleted its assets by hostname. The problem: on an install whose hostname column is narrower than the schema says (it happens - Database Verification adds missing columns but never widens them), MySQL quietly shortens the name. The cleanup found nothing, and the rows were left in a real database. Now: it records every id it creates and deletes by id. Clean up by what you created, never by what you think it is called.
| # | Finding | Verdict |
|---|---|---|
| 1 |
external_reference where the design says client_reference
|
Change |
| 2 | No GET ?client_reference=
|
Change |
| 3 | The test fired real workflows | Change |
| 4 | Per-analyst uniqueness of the reference | Keep |
1. The name. Your code called the retry key external_reference. The problem: the agreed design, and the task-form submissions built the same week (#190), call it client_reference. Two names for one idea in one API is something every integrator would trip over. Now: client_reference everywhere - the column, the parameter, the response and the spec. It was free to change, because nothing had been released.
2. "Did it arrive?" The design promised GET /changes/{id}/attachments?client_reference=β¦, so a pipeline whose upload timed out can ask before sending again. Now: it exists. The answer is the match or an empty list, never an error.
3. The test. Your test created its change with ChangesService::createChange(). The problem: that fires the real change.created workflows. On a development database that is a copy of somebody's live data, those workflows send real emails and open real tickets. Now: the test inserts the change row directly. A test never goes through a path that notifies, emails or runs automation, unless that is what it tests.
4. Kept: uniqueness per change and per analyst is stricter than the design asked for, and better. Two integrations can never collide on a reference.
Also tidied: the three operations sit in the spec's Change comments, history & CAB section beside their neighbours, and the test helper passes query strings through.
This was the big one, and the most carefully built: versioned input, strict validation, a per-section watermark and a content fingerprint. It was also where the contract and the existing code needed the most alignment, as @Dr4inder said it would.
| # | Finding | Verdict |
|---|---|---|
| 1 | π΄ The agent stopped replacing Intune's stand-in volumes | Change |
| 2 | π΄ Ownership per asset let one integration clear another's rows | Change |
| 3 | π΄ One bad section refused the whole report | Change |
| 4 | Delete-and-reinsert on every report | Change |
| 5 | Field names differ from the agreed design | Change |
| 6 | No way to unlink a device | Change |
| 7 | New applications skipped the workflow trigger | Change |
| 8 | Analyst columns missing from the leaver map | Change |
| 9 | The upgrade test altered real tables | Change |
| 10 | 22 hand-written translations | Leave out |
| 11 |
db_verify_indexes.php edited by hand |
Change |
| 12 | Fingerprint instead of a 30-day report_id
|
Keep |
| 13 | The rest: namespace for stored sources |
Keep |
| - | Physical drives as a section; a sources panel on the asset page | Not yet - follow-ups |
1. π΄ Agent regression. Your code narrowed the agent's volume delete to source = 'agent', so it would leave REST rows alone. That intent is right. The problem: the agent used to replace Intune's volumes too, and they rely on that. Intune only writes a stand-in volume for machines the agent does not report (intuneSyncDisks() skips any asset with agent rows). With the narrowed delete, a machine Intune saw first would show its Intune volume beside the agent's for ever. Now: source IN ('agent', 'intune'), with a TRAP: comment, and a new check in tests/agent-empty-report-guard.php. That check fails on the PR's version and passes on the fix. When you narrow a delete, find every writer of that table first and ask who relied on the wider one.
2. π΄ Ownership. Your code bound each (asset, source) to the analyst who first wrote it. The problem: the binding was per asset. A second integration that sent the same source name could not touch assets the first had reached, but on every asset the first had not reached yet, it became the owner. It could then send a complete empty section and clear that source's rows as they arrived. That is exactly what @Dr4inder's own clarification 1 asked to prevent. Now: a source name belongs to one analyst per company, in asset_inventory_source_owners. A rotated key acts as the same analyst, so it keeps the source; anyone else gets 403 source_owner. Built-in names (agent, intune, ...) are refused outright.
3. π΄ Partial success. Your code refused the whole report if any section was invalid or conflicted. The problem: the agreed contract (design, and @Dr4inder's clarification 3) was the reverse. The good sections apply, the failed ones are named, and a retry must not be blocked by its first failure. Now: each section runs inside its own savepoint. A failure rolls back that section alone and records no watermark, so sending the same report again retries only the failed sections, and the applied ones answer replayed. 200 when nothing failed, 207 when some did, 422 all_sections_failed when none applied. Problems with the report as a whole (the envelope, ownership, a device linked elsewhere) still refuse all of it.
4. Reconciliation. Your code deleted the source's rows and inserted the new list each time. The problem: every row got a new id on every report, and the response could not say what had changed. Now: rows are matched by identity (the application, or your external_id) and updated in place, new ones are added, and - for a complete section only - the missing ones are removed. The response gives added, updated and removed counts per section.
5. Names. source_id / external_device_id became source / source_device_ref, as in the design. source_id also reads like a foreign key. Item fields keep the tables' own names (display_name, mac_address), which the read endpoints already use - a sensible choice, kept.
6. Unlinking. The design made re-linking a deliberate act: DELETE /assets/{id}/inventory/{source} first. The PR had no way to do it, so a reimaged machine's device reference was stuck on its old asset. Now: the endpoint exists, behind asset_inventory.delete, and it removes that source's rows from the asset and writes to the history. Making it work meant one small change to the REST router: path parameters that are not numbers now arrive as strings. Every route before this one used ids only.
7. Workflow trigger. Your code added unseen applications to the catalogue, as the agent and Intune do. The problem: they also fire software.application_discovered, and people build workflows on it. Now: it fires, after the commit, and only for applications whose section actually applied.
8. Leaver map. Every column that names an analyst must be in analystReferenceRegistry(), or deleting a test analyst fails. Now: source owners are setup (whoever runs that integration now has to take the name over) and section importers are history. The same check showed nine columns from my own work this month that were missing as well. They are fixed in the same commit (changelog #2489).
9. The upgrade test. Its header said no persistent data was modified, and the software table was indeed shadowed by a temporary copy. The problem: the upgrade function also widens two real tables and adds two foreign keys, and nothing shadows those. Now: the test refuses to run until Database Verification has done the upgrade, so on the real tables it can only be a no-op.
10. Translations - leave out. New strings go into lang/en only. The other locales are translated in one pass before each release, checked by native-speaker rules and a glossary. Hand-written entries get in the way of that.
11. Generated file. includes/db_verify_indexes.php is generated from freeitsm.sql by php scripts/gen_db_verify_indexes.php, and CI fails when the two disagree. Change the SQL and run the script.
12. Kept: the fingerprint. The design asked for a report_id remembered for 30 days. Your observation time plus a content fingerprint, per section, does the same job and never expires. That answers @Dr4inder's worry about replay protection ending with a cache. The design page now says so.
13. Kept: rest:. Storing REST rows as rest:<name> means they can never be mistaken for the agent's or Intune's, whatever name is sent.
Not one implementation - yet. The design wanted the agent and the API to share one inventory implementation. The REST path shares the catalogue rules and the trigger, but the agent's own code is unchanged apart from fix 1. Moving the busiest write path in FreeITSM onto new code deserves a release of its own.
All of it is in the three review commits listed at the top. Tests at merge:
| Suite | Result |
|---|---|
tests/asset-api-fields.php |
28 passed |
tests/change-api-attachments.php |
21 passed, plus a manual check that the app's own upload still works through the shared service |
tests/asset-api-inventory.php |
34 passed (rewritten for the contract above) |
tests/asset-inventory-upgrade.php |
5 passed |
tests/agent-empty-report-guard.php |
14 passed, with a control that fails on the PR's agent change |
tests/analyst-lifecycle.php |
21 passed |
api/v1/lib/openapi_check.php, schema drift, web-exposure guards |
pass |
- Contributing - the lessons from this and earlier reviews, in one place
- API inventory and evidence - design
- Earlier reviews: Asset reconciliation, tags and labels (#164) Β· SSO JIT roles and profile sync (#155) Β· Checklists closure gating
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π Display order (modules, Watchtower cards) β Developer Guide
- 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: Projects
- β³ ποΈ Mobile: Files
- β³ π 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
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π« 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: Analysts
- β³ πΊοΈ 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
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ 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
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π 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
- ποΈ Files
- 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
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ 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