Back to blog
🏷️ v0.9.0 May 20, 2026

v0.9.0 — Runbooks, label printer + consumables, agent-only KB, RBAC tightening

Runbooks: KB articles with checkboxes + canned-response pills that drive the comment composer. Zebra label printer with asset/consumable labels + QR deep-links. Asset tags and a custody check-out / check-in log on Inventory. New Consumables module with a ledger. Agent-only KB visibility tier. RBAC tightening — Inventory is handler-only, vendor-share gate now matches the notes ACL. Auto-provision Submitter accounts from inbound senders. Canned responses + vendor outbound now render markdown. Mobile ticket tabs collapse to a select.

v0.9.0 is two stories stitched together. The big one — runbooks + label printer + consumables — is physical-world stuff: paper labels stuck on hardware, parts coming off a shelf, a checklist a tech runs at the bench. The small one is a stack of permission cleanups that bring the project-handler model (Notes / agent_only) into the rest of the app.

Runbooks

A runbook is a KB article with kind='runbook'. Same editor, same versioning, same agent-only flag. The difference is rendered on the ticket: there’s a new Runbook tab that lists every runbook started against the ticket, with persistent checkbox state per step.

Runbook on a ticket

The two important wins:

  • Per-ticket state, not per-user. Hand a half-finished VPN runbook off at end of shift and the next handler picks up at the checkbox where you stopped. Each step records who ticked it + when.
  • Canned-response pills. Anywhere in a step’s text you can write @canned:Vendor escalation or @canned:[Multi-word title here]. On the ticket runbook panel that turns into a 📋 button. Click it and the canned-response body — with {ticket.ref} / {submitter.firstName} / etc. already substituted server-side — drops into the comment composer.

You make a runbook the same way you make any KB article: KB → New → Kind: Runbook. Use BlockNote’s check-list-item blocks for the steps; everything else (headings, paragraphs, bullets) renders as section labels around the boxes.

[ ] Identify symptom — confirm VPN profile, capture TCP trace
[ ] Schedule GPO revert, pilot on IT OU
[ ] If vendor needs to engage: @canned:Vendor escalation
[ ] Post customer-facing summary: @canned:Confirm root cause
[ ] Capture findings as a KB article, link from Resolution tab

Storage is a small ticket_runbook_runs table — one row per (ticket, runbook) with a JSONB step_states blob. Step toggles use a Postgres jsonb || merge so two handlers ticking different boxes at the same time don’t clobber each other.

The runbook tab is handler-only — same gate as Notes (global Admin/Manager/Tech, or a project member with a handler role override / Agent flag).

Agent-only KB articles

The Notes feature has had a clear “this is handler-only” semantics since v0.8.0. KB articles inherit it now. Flip Agent only on any KB article and:

  • The article disappears from listing + suggestions for anyone who isn’t a project handler.
  • 404 (not 403) on direct slug fetch — keeps the slug invisible.
  • Only a global Admin can delete an agent-only article. Tech / Manager with project-handler access can read + write but not delete.
  • Tag aggregation excludes agent-only articles, so the topic chip row doesn’t leak hints.

The same gate (isProjectHandler helper) is now used by Notes, agent-only KB, runbooks, runbook progress writes, the vendor-share comment toggle, and the inventory tab — one source of truth.

Agent-only article in the project list

Zebra label printer

Single-row label_printer_config (host / port / dpi / media dimensions / top-offset / property-line text). The service module opens raw TCP :9100 with an 800 ms post-write hold so the printer can ack before tear-down — the nc -w3 truncation that bit us during manual tests doesn’t repeat here.

Three label kinds:

  • Test label — wires Admin → Site → Label printer’s Print test button to a known-shape job so you can validate alignment without burning a real asset.
  • Asset label — first line is asset_tag (faux-bolded via a 1-dot horizontal double-strike since the scalable font has no bold weight), then hostname (only when distinct from tag), a blank visual row, then the property-line. QR is right-justified inside the die-cut radius; text column clamps to qrX − 23 dots so a long hostname truncates instead of overlapping the code.
  • Consumable label — same layout, part_no on the lead line, vendor on the property line.

QR encodes the inventory deep-link: FRONTEND_URL + /inventory/<id>. Scan with an iPhone in the field and you land on the right Resolvd row.

Admin → Site → Label printer

Inventory and Consumables both get a Print label button. Top offset accepts -120..+120 dots because some printers have a positive bias the operator wants to correct.

Asset tags + custody log

Operator-facing inventory IDs that survive RMM syncs. RMM-managed rows keep all structural columns synced from Action1, but asset_tag is admin-owned — COALESCE’d on update so an admin-supplied tag persists even when the source has the slot empty.

asset_checkouts tracks possession over time. One row per check-out / check-in pair, separate columns for holder + actor (out_by / in_by), free-text notes. A partial unique index on asset_id WHERE in_at IS NULL enforces one active checkout per asset; a double check-out 409s at the route.

Asset detail with custody log

The custody panel sits on the asset detail page. Check out → set holder → optionally note (shipped via FedEx, kept at desk); check in → reverses. linked_user_id auto-tracks the active holder, so the same UI that previously linked a single Action1-reported user now reflects current possession.

Consumables module

Toner. Spare drum kits. UPS batteries. Replacement keyboards. The stuff Inventory shouldn’t track per-serial because it’s interchangeable.

Schema:

  • consumablespart_no (unique), vendor_company_id, current_stock, low_stock_threshold, notes, archive flag.
  • consumable_movements — append-only ledger: delta, reason, ticket_id, by_user_id, at, note.

Every stock change runs UPDATE + ledger INSERT in the same transaction. Direct PATCH /current_stock is rejected with 400 "use /:id/move" so you can’t bypass the audit trail.

Consumables list with low-stock flags

UI is Consumables in the top nav, parallel to Inventory:

  • List page with search, low-stock chip, archive toggle.
  • Detail page with stock card, inline edit, +/- stock controls, the full ledger table, and a Print label button.
  • Per-item history shows every +5 received from Newegg, -1 issued to ticket ACME-0042, -2 disposed (recall).

Low-stock is purely informational right now — surfaces a red badge but doesn’t fire a notification. Reorder alerts via the existing notification matrix is a follow-up.

RBAC tightening

Three permission gaps closed:

  • Inventory is handler-only. The sidebar link is hidden for non-handlers, a handlerOnly route protector blocks direct navigation, and every GET /api/assets/* requires Admin/Manager/Tech. Submitters / Viewers had no reason to see hostname lists — now they don’t.
  • Bulk assignee picker on the ticket list used GET /api/users, which let admins assign to Submitters / Viewers. Replaced with a project-scoped Agent-flag pool — single-project selection scopes to that project; mixed scopes return the org-wide eligible set.
  • Vendor-share toggle on the comment composer now allows any project handler (matches the Notes ACL, factored into isProjectHandler). A Submitter granted Tech-tier on one project can finally share customer-facing comments on that project’s tickets — fixes the gap that made the role override feel half-done.

Markdown reach: canned responses + vendor email

Two surfaces that were stuck on plaintext got markdown:

  • Canned response body in admin: <textarea>MarkdownEditor (Write / Preview tabs, formatting toolbar, AI rewrite). Same component the ticket composer uses. The [label](url) hint is shown under the field.
  • Vendor outbound email runs the template body through marked with an allowlist of p / a / ul / ol / li / h1-h6 / em / strong / code / blockquote / br tags. Anchors get target="_blank" rel="noopener noreferrer". Raw HTML from the source is dropped by the sanitizer so a poisoned template can’t inject anything.

Inbound flow polish

  • Match by ticket ref. The admin match flow on Admin → Inbound now accepts either a numeric internal id or a ticket reference (ACME-0042). The lookup is case-insensitive and ignores leading #.
  • Auto-provision Submitter accounts. Unknown senders arriving via inbound email or alert integrations now mint a default Submitter user instead of falling to the manual queue. When Entra is configured, a Graph /users/{email} lookup populates display_name, entra_oid, upn so the user can SSO immediately. Vendor-domain senders are decline-listed against companies.kind = 'vendor' — those still arrive as contacts.
  • Vendor-reply lookup ignores empty blind_idx, fixing a class of mismatches where a contact row had a NULL/empty plaintext-email blind index.

Mobile + button uniformity

  • Mobile ticket tabs collapse to a single full-width <select> below the 640 px breakpoint. Six tabs (Comments, Notes, Runbook, Attachments, Resolution, Audit) overflowed the strip + bled into the description column. Tablet+ keeps the strip but adds flex-wrap.
  • Desktop button heights are now uniform. .btn carries a border border-transparent so bordered variants (btn-secondary) and borderless ones (btn-primary) render at the same total height. index.css documents the policy: .btn (~36 px) for header CTAs / modals, .btn-sm (~24 px) for dense table rows.

Upgrade notes

  • New columns: assets.asset_tag, asset_checkouts table, consumables + consumable_movements tables, kb_articles.kind, ticket_runbook_runs table, label_printer_config (1 row). All IF NOT EXISTS, no manual migration.
  • New nav entry: Consumables. Visible to handlers only.
  • Label printer requires a configured LABEL_PRINTER_HOST / port (raw TCP :9100). Unconfigured = Print buttons disabled with a hint pointing at Admin → Site → Label printer.

Full changelog on GitHub.