Changelog
Every GitHub release includes the full set of fixes and upgrade notes. Links below point to the release page for the complete list. This page is a shorter, docs-site-friendly summary.
v1.10.1 -- admin token validator fix + inventory adjustment CSV import
2026-05-11. Full notes.
Patch release on top of the v1.10.0 POS surface. Two operator-experience changes.
admin/tokens validator accepts dockd.dispatch and pos.dispatch.
CreateTokenRequest and UpdateTokenRequest only recognized V150
outbound slugs in _known_slugs_only, so POST /api/admin/tokens with
endpoints=['pos.dispatch'] or ['dockd.dispatch'] returned 400
unknown_endpoint_slugs even though the auth middleware honors both
slugs at request time and scope-catalog advertises them. Operators
could not issue dockd or POS tokens through the admin API. New
_KNOWN_ENDPOINT_SLUGS = V150 keys + V190_DOCKD_SLUG + V1100_POS_SLUG
is the single source the validator's accept set and "unknown slug"
error message both read from.
Inventory adjustment CSV import (#329). New inventory-adjustments
arm on POST /api/admin/import/<type> alongside items / bins /
purchase-orders / sales-orders. Required columns: sku, warehouse,
bin, qty (signed integer), memo (optional, <=500 chars). Each
accepted row resolves sku against items.sku, warehouse against
warehouses.warehouse_code, bin against bins.bin_code (must
belong to the resolved warehouse), and writes an
inventory_adjustments row with reason_code='CORRECTION',
status='APPROVED', reason_detail=memo so the on-hand change applies
inline. Positive qty goes through
services.inventory_service.add_inventory (advisory-locked, creates
the inventory row when absent); negative qty takes FOR UPDATE on the
inventory row and rejects with a row-level error when available
on-hand is insufficient. One audit_log row (ACTION_ADJUST) and one
adjustment.applied/1 outbox event fire per row so subscribers see
one event per imported correction. Existing 5000-record cap and V-015
formula-prefix sanitiser apply unchanged. Admin Imports page gains an
"Inventory Adjustments" tab with template download (5 columns, 3
example rows). New alignment test pins the template header to the
server schema field list.
No migrations. Zero mobile/ diffs on this branch; v1.9.0 APK
(sentry-wms-v1.9.0.apk, versionCode 6) remains the working baseline.
No new APK build for v1.10.1. Operators running the v1.9.0 mobile app
continue to work against a v1.10.1 backend with no upgrade.
v1.10.0 -- POS endpoint surface
2026-05-09. Full notes.
Sentry now serves a dedicated counter-sale API for an external POS
Service. Four endpoints under /api/v1/pos/ (GET /availability,
POST /validate-cart, POST /checkout, POST /refund) authenticate
via a new fourth direction pos.dispatch alongside outbound polling,
inbound POST, and dockd. Checkout and refund are atomic single-
transaction routes with SELECT ... FOR UPDATE on the inventory rows
being decremented or re-incremented, idempotent on a per-route
idempotency_key (UUID4) with a SHA-256 body hash so a retry with the
same key + same body replays the cached response and a retry with the
same key + different body returns 409.
Refund enforces a 90-day window from the original sale's created_at,
a card-vs-cash tender lock comparing the original POS_CHECKOUT audit
row's payment_method against body.refund_summary.method, and a
once-per-original-SO guard via refunded_at / refund_so_id on the
original sales_orders row. Missing / out-of-scope / wrong-source /
wrong-state original SOs conflate to 404 original_so_not_found to
prevent enumeration; the 422 informational rules only fire after the
token has proven it can see the SO.
PCI-scope guard at the Pydantic boundary: CardTender is a strict-
typed model with extra='forbid' accepting exactly {type, amount_cents,
card_brand, card_last4, auth_code, external_ref}. Any other field
(card_pan, full_track, expiry, cvv, etc.) fails 422 at the schema
layer so Sentry never accepts PAN-shaped data on the wire.
Pricing stays out of Sentry's columns: per-line unit_price_cents /
tax_cents / line_total_cents ride on the wire and live exclusively
in audit_log.details for archival; mig 056 added no per-line price
columns. The POS Service owns its own pricing source. New
ACTION_POS_CHECKOUT and ACTION_POS_REFUND audit constants extend
the v1.4 hash chain.
One migration (056). No new APK published; v1.9.0 APK
(sentry-wms-v1.9.0.apk, versionCode 6) remains the working baseline
since v1.10.0 has no mobile changes. Operators running the v1.9.0
mobile app continue to work against a v1.10.0 backend with no upgrade.
v1.9.0 -- Dockd shipping integration
2026-05-09. Full notes.
Sentry now serves a dedicated outbound shipping API for the in-warehouse
dockd application. Three endpoints under
/api/v1/dockd/orders/<so_number> (GET, ship, void-ship) authenticate
via per-station bearer tokens with the new dockd.dispatch scope, are
idempotent under retry through SHA-256 body-hash sentinel rows, and
serialize concurrent ship attempts on the same SO with
SELECT ... FOR UPDATE. Both ship and void-ship write through the
existing audit-log hash chain and emit on the integration_events
outbox so downstream ERPs see a fully-shipped or fully-reversed order.
In parallel, the SO lifecycle gains CANCELLED status with end-to-end
wiring (admin + inbound + dashboard counter); a new sales_orders.memo
column inbound-mappable from connector and rendered through the picker,
packer, and shipper flows; and a UI modernization of the Audit Log page
with color-coded action badges, chip-style detail previews, an
action-type select filter, and a Copy JSON button on the detail modal.
Audit details for PICK / TO_LINE_PICKED / PACK / RECEIVE actions now
record both expected and actual counts so investigators can reconstruct
cumulative state from one row.
Two migrations (054-055). Migration 054 adds five void columns to
item_fulfillments and the dockd_idempotency table; 055 adds
sales_orders.memo TEXT. Both forward-only.
Mobile. The v1.8 APK
(sentry-wms-v1.8.0.apk)
stays a working baseline -- v1.9 backend changes are additive and v1.8
keeps picking + packing + receiving + putaway against a v1.9.0 backend.
The v1.9 mobile build adds a memo block on Pack / Pack-Ship / Ship
screens (warning-tinted callout above the scan input) and fixes a
pack-after-short-pick fallback bug where PackScreen and
PackShipScreen used || against quantity_picked, falling back to
quantity_ordered on a fully-shorted line and blocking pack
completion. Update to the v1.9 APK on the
release page
if you ship from the mobile flow or want the memo display, or stay on
v1.8 if you don't.
v1.8.0 -- Transfer Orders + Productivity Dashboard
2026-05-07. Full notes.
Sentry now ships its first internal warehouse-to-warehouse workflow
end-to-end: import a TO via CSV (with shortage detection + per-line
commit), pick through the existing mobile flow via a new
pick_tasks.to_id discriminator, batch picks into an admin-approval
row, approve to move inventory source -> destination + emit
transfer.completed/1 to the outbox, or reject to leave the source
stock for re-pick. The operations-overview Dashboard is replaced
with a per-user productivity grid (Picking units / Packing units /
Shipped orders / Received unique SKUs / Put Away unique SKUs)
backed by audit_log aggregation through a new compound covering
index.
The v1.7.0 inbound contract gains sales_orders.order_total +
customer_shipping_paid (NUMERIC(12,2)) with per-field decimal
bounds in mapping docs (rejected at 422 instead of silent Postgres
rounding); structured per-component billing + shipping address
fields (16 columns drop the v1.7 single-TEXT placeholders); inbound
line items write through to purchase_order_lines +
sales_order_lines so receiving + picking have something to scan
against; per-token static mapping_overrides JSONB resolves the
v1.7 deferral (#270); inbound payload warehouse_id falls back to
the issuing token's primary warehouse when source omits it.
Five migrations (049-053). Three security carry-forwards close the
v1.4 deferral set: scrub_secrets credential pattern catalog,
ConnectionResult.message scrub-before-truncate, \r permitted
with JSON-escape on emit. Breaking: v1.7
sales_orders.billing_address + shipping_address TEXT columns
are dropped in favour of the structured fields; mapping docs that
reference the old names fail boot loud via the #267
canonical-column validator.
Mobile. The v1.5.1 APK
(sentry-wms-v1.5.1.apk)
stays a working baseline -- backend changes in v1.6 / v1.7 / v1.8
are additive and v1.5.1 keeps picking + packing + receiving +
putaway against a v1.8.0 backend. The v1.8 mobile build adds two
cosmetic improvements for the new TO surface: the picker screen
header reads "TO {to_number}" instead of "X orders" when the active
batch is a TO pick, and the home-screen banner flips its label to
"ACTIVE TRANSFER" + detail line "TO {to_number}". Operators on
v1.5.1 picking a TO batch see the legacy "X orders" text (which
renders "0 orders" since the batch has no SO links) -- functional
but ugly. Update to the v1.8 APK on the
release page
for the new TO display, or stay on v1.5.1 if you don't run TO
workflows.
Transfer Orders:
- Three new tables (#281, mig 049):
transfer_orders(header with source / destination / status + UUID external_id + state- machine CHECK),transfer_order_lines(per-item with monotonicity CHECKscommitted <= requested,picked <= committed,approved <= picked),transfer_order_approvals(one row per picker submission withlines_snapshotJSONB + UUID external_id for outbound event idempotency).pick_tasksgainsto_id+to_line_iddiscriminator with an XORCHECKso exactly one of(so_id, to_id)is non-NULL; existingso_id/so_line_iddrop their NOT NULL so SO + TO pick rows share the same table. - TO number generator (#290) -- format
TO-{YYYYMMDDHHMMSSmmm}matching the existingpicking_servicebatch numbering at millisecond precision. UNIQUE onto_numbercatches the rare same-millisecond burst; the route retries once with a fresh timestamp before surfacing 500. - CSV import (#291).
POST /api/admin/transfer-orders/importaccepts{source_warehouse_code, destination_warehouse_code, notes, records: [{sku, quantity}]}. Pipeline: top-level Pydantic - source != destination + warehouse code -> id resolution + per-row
TransferOrderImportRowvalidation with formula-prefix protection - SKU resolution to
items.item_id+ sort byitem_id ASC+FOR UPDATE OF invwalk per item across bins (matches picking + cancel + start-picking lock ordering) + commitmin(requested, available)distributed across bin rows. Lines withcommitted_qty = 0landSHORT_CLOSEDso they don't block closure. Response carries header + shortages payload so the Shortage Modal renders cleanly. - Picking dispatch (#292).
POST /api/admin/transfer-orders/<to_id>/start-pickingwalks TO lines withpicked_qty < committed_qty, finds inventory rows at the source warehouse, INSERTs onepick_tasksrow per(line, bin)withto_id+to_line_idset, anchors them under a freshpick_batch.picking_service.confirm_pickbranches on the discriminator: TO picks callupdate_transfer_order_line_picked(atomic over-pick rejection via WHERE-clause guard) and writeACTION_TO_LINE_PICKEDaudit; SO picks unchanged. - Submit + approve + reject (#293).
POST /api/admin/picker/transfer-orders/<to_id>/submit(cookie auth, no role gate) snapshots lines withpicked_qty > approved_qtyinto a PENDING approval row + flips header to AWAITING_APPROVAL when all lines fully picked.POST /api/admin/transfer-orders/<to_id>/approvals/<id>/approveenforces a self-approval gate viaapp_settings.transfer_order_block_self_approval(mig 049 seeded TRUE), bumpstransfer_order_lines.approved_qty, decrements sourceinventory.quantity_allocated+quantity_on_handdistributing across bins, credits destination warehouse's first Staging bin (INSERTs row when missing; 409no_destination_staging_binotherwise), checks closure, emitstransfer.completed/1withaggregate_id = to_approval_id.POST /api/admin/transfer-orders/<to_id>/approvals/<id>/rejectflips status to REJECTED with optionalrejection_reason; no inventory movement, no event emission so source stock stays available for re-pick. - TO confirm_pick does not decrement source inventory (#293).
v1.8 splits the SO + TO inventory semantics: TO picks update only
transfer_order_lines.picked_qtyat pick time (the import-time reservation persists); inventory moves source -> destination at approval time. SOconfirm_pickunchanged. - Admin UI (#294). Single-file
admin/src/pages/TransferOrders.jsxwith list + status filter + source / destination filter, detail modal with lines table + approvals queue, per-line Short-Close - Cancel + Delete + Start Picking action buttons, CSV Import
modal with client-side parse + preview + per-row error feedback,
Shortage Modal with three actions (Download Shortage CSV,
Cancel TO, Create with Available), Approve / Reject buttons on
pending approvals. Sidebar entry under Warehouse group; existing
/inter-warehouse-transfersrenamed to "Bin Transfers" to disambiguate. Seedocs/transfer-orders.mdfor the full operator playbook. - Mobile picker TO context (#295).
/api/picking/active-batch get_batch_tasks+get_next_taskLEFT JOINtransfer_ordersso TO tasks resolve; response gainskind+to_id+to_number. Mobile picker screen renders the TO context (see Mobile note above).- Sidebar pending-approvals badge (#296).
/admin/dashboardreturnspending_to_approvalswarehouse-scoped to TOs whose source OR destination matches the requested warehouse_id.
Productivity Dashboard:
- Service (#297,
api/services/productivity_service.py).DASHBOARD_EVENTScatalog maps slug -> (action_type, metric_kind):picking(PICK / units),packing(PACK / units),shipped(SHIP / orders),received_skus(RECEIVE / unique_skus),putaway_skus(PUTAWAY / unique_skus). Per-event aggregator with the actual JSONB field path per metric (PICK usesdetails.quantity_picked, PACK usesdetails.total_items, RECEIVE usesdetails.item_idfor distinct count, PUTAWAY usesentity_id). 60s in-process TTL cache keyed on(warehouse_id, start, end). Packing visibility honoursapp_settings.require_packing_before_shipping. - API endpoints (#297).
GET /api/v1/dashboard/productivity(cookie + ADMIN, Pydantic-validated date range capped at 90 days, 422 onend < startorrange_too_large).GET /api/v1/dashboard/preferences(returns schema defaults when no row exists).PUT /api/v1/dashboard/preferences(upserts, partial body keeps other fields,chart_ordervalidated against the catalog allowlist,user_idderived fromg.current_useronly -- never from body). - Frontend (#299,
admin/src/pages/Dashboard.jsxrewrite). 5-card grid (4 when packing hidden) with per-user vertical bars sorted desc by event value, top performer in Sentry red#8e2715and others in copper#c4722a. Time range selector Today / Yesterday / Last 7d / Last 30d / Custom. Charts (default) / Table view toggle with CSV export from table view. Click-to- expand replaces grid with full-size single chart + Back button. Gear-icon settings panel for chart_order rearrange + default_range - default_view; PUTs preferences on every change.
Inbound contract extensions:
sales_orders.order_total+customer_shipping_paid(#282, mig 050). TwoNUMERIC(12,2)nullable columns. Forward-only -- existing rows have NULL after the migration.- Per-field decimal bounds in mapping docs (#285).
FieldMappinggains optionalmax_digits/decimal_places/ge/leattributes fortype='decimal'._coerce_or_defaultalways coerces decimals toDecimal(safe for psycopg2) and raisesValueError(-> 422mapping_apply_error) on bound violation, replacing the v1.7 silent Postgres rounding (excess scale) and 500 NumericValueOutOfRange (excess precision). Backward compatible: existing decimal mappings without bounds keep pass-through behaviour. - Structured billing + shipping address (#288, mig 053). 16
structured columns replace the v1.7 mig 046
billing_address+shipping_addressTEXT placeholders. CSV import + admin SO detail render + admin SOPATCH /addressendpoint with status gate (ADMIN any status / non-admin OPEN only) +ACTION_SO_ADDRESS_EDITEDaudit with field-level delta. Operator template gets 16 worked examples replacing the 2 TEXT examples. - Inbound line item write-through (#289).
purchase_orders+sales_ordersinbound now writes line items to the relational*_linestables (v1.7 stored them only ininbound_*.canonical_payloadJSONB). Item resolution viacross_system_lookup(line declaresitem_idwithsource_type: item); helper dereferences the canonical UUID to the integeritems.item_id. Re-POST replaces lines via DELETE + INSERT only when no downstream activity exists; POquantity_received > 0or SOquantity_(allocated|picked|packed|shipped) > 0returns 409lines_in_flight. Emptyline_itemsarray on re-POST preserves existing lines (header-only update is allowed). Items must be pre-loaded so the lookup resolves; unresolved item -> 409cross_system_lookup_miss. - Per-token static
mapping_overrides(#270, mig 052).wms_tokensgainsmapping_overrides JSONB NOT NULL DEFAULT '{}'. The existingmapping_override BOOLEANcapability flag stays as the gate; per-token overrides apply only when both the boolean is TRUE and the JSONB is non-empty. Admin issue route validates every override key against the columns of the token'sinbound_resourcescanonical tables viainformation_schema(422unknown_mapping_overrides_keys). Audit shape uniform: every TOKEN_ISSUE / TOKEN_ROTATE / TOKEN_DELETE row carriesmapping_overrides_keys(sorted, never values). Seedocs/erp-integration.mdfor the operator-facing reference. warehouse_idtoken fallback (#300). When source omitswarehouse_idand the token'swarehouse_idsarray carries at least one entry, the inbound handler fills intoken.warehouse_ids[0]. Single-warehouse tokens (the common case for connector authors) get the natural fallback; multi-warehouse tokens take the first entry.
Security carry-forward:
scrub_secretscredential pattern catalog (#52). NewCREDENTIAL_PATTERNScovers Sentry's own bearer tokens, AWS access keys, generic Bearer headers, key=value connection-string fragments, NetSuite OAuth fragments, JWT-shaped strings, and a heuristic catch-all for long base64-ish strings near credential keywords.scrub_secretscomposes URL scrubbing + the new catalog and is idempotent.ConnectionResult.messagecredential scrubbing (#53). Runsscrub_secretsbetween the printable-character filter and the 500-char length cap so multi-character redaction tags (<REDACTED>,<JWT_REDACTED>) cannot be split.- Carriage return in
ConnectionResult.messageallowlist (#55).\rstays in_ALLOWED_MESSAGE_CHARSso Windows-origin upstream errors (\r\nline endings) survive intact. Safety on emit guaranteed by JSON encoding (Pydanticmodel_dump_jsonescapes\rto\\r).
Migrations:
- 049 -- transfer orders (
transfer_orders+transfer_order_lines+transfer_order_approvals+pick_tasksto_id/to_line_iddiscriminator with XOR CHECK +app_settings.transfer_order_block_self_approval). XOR CHECK landsNOT VALIDthenVALIDATEoutside the BEGIN/COMMIT so the validation lock isSHARE UPDATE EXCLUSIVErather thanACCESS EXCLUSIVE. - 050 --
sales_orders.order_total+customer_shipping_paid(NUMERIC(12,2), nullable). - 051 --
user_dashboard_preferencestable +ix_audit_log_dashboardcovering index +warehouses.timezone(default'America/Denver'). - 052 --
wms_tokens.mapping_overrides JSONB NOT NULL DEFAULT '{}'. - 053 -- structured billing + shipping address columns (16 VARCHAR
columns replacing the v1.7
billing_address+shipping_addressTEXT).
All five declare SET lock_timeout = '5s' + SET statement_timeout
= '60s' at the top so a bad migration fails fast (new v1.8
convention). BEGIN/COMMIT-wrapped per V-213.
Breaking changes:
sales_orders.billing_address+shipping_addressTEXT columns dropped (mig 053). Replaced with 16 structured per-component columns. Mapping docs that still reference the old names fail boot loud via the #267 canonical-column validator with the offending file path + field name.- Old operations-overview Dashboard removed (#299).
/admin/dashboard(legacy ops-overview JSON) stays for sidebar badge counts, but the admin panel's/route now renders the per-user productivity grid; the previous open SOs / open POs / short-picks tables are dropped from the dashboard surface. Operators find them on/sales-orders,/purchase-orders, and/audit-log.
Reserved for v1.9:
- Power User role (#298). Third role tier between USER and ADMIN that admits admin panel login but locks the System sidebar group.
ship.confirmed/1event payload extension for structured shipping address (deferred from #289). v1.9 dockd integration is the actual consumer.- Per-request body
mapping_overrides(#270 follow-up). Per-token static config (Option B) is the v1.8 surface; per-request body (Option A) and per-mapping-document escape hatches (Option C) remain deferred until real demand surfaces.
Operator notes:
- After deploy: restart api workers so the new
mapping_overridescolumn is in the token cache shape. Existing tokens auto-populate with'{}'from the migration's NOT NULL DEFAULT. - TO inventory locking pattern: import + cancel + start-picking +
approve + short-close all walk inventory rows in
inventory_id ASCso concurrent SO + TO operations on the same item stay deadlock-free. - Productivity Dashboard cache: 60s TTL per
(warehouse_id, start, end)per worker. Restart workers if you need fresh reads inside the TTL. - Transfer Order destination warehouse: must have at least one
Staging bin. Approve fires 409
no_destination_staging_binotherwise.
v1.7.0 -- Inbound (Pipe B)
2026-05-06. Full notes.
External systems can now POST canonical-shaped resource updates to
Sentry through five new endpoints under /api/v1/inbound/:
sales_orders, items, customers, vendors, and
purchase_orders. Each request carries external_id +
external_version + source_payload; per-source mapping documents
(YAML at db/mappings/<source_system>.yaml) translate the source
payload into Sentry's canonical model with strict-typed Pydantic
validation, JSONPath resolution, simpleeval-sandboxed derived
expressions, and cross_system_lookup for canonical UUID resolution
against prior ingestions. X-WMS-Token authentication gains
source_system + inbound_resources scope dimensions on top of
the v1.5 endpoint scope. inbound_source_systems_allowlist gates
which source systems can POST; misconfigured allowlist or missing
mapping doc refuses boot loud.
Twelve new migrations (037-048). One new env var
(SENTRY_INBOUND_SOURCE_PAYLOAD_RETENTION_DAYS); two existing env
vars gain new shape (SENTRY_INBOUND_MAX_BODY_KB boot-validated,
SENTRY_INBOUND_MAPPINGS_DIR default changed to absolute
/db/mappings). Three boot validators reject misconfiguration loud
at startup: canonical-column shape (#267), eval-shape derived
expressions (#272), and SENTRY_INBOUND_MAX_BODY_KB range (#273).
audit_log strict-by-log_id chain integrity hardened against
concurrent insert via sentinel-lock + nextval-in-trigger (#271).
Direct-DB revoke of wms_tokens.revoked_at propagates auth
invalidation across workers via pg_notify trigger + LISTEN
subscriber + lock-step status flip (#274, #278).
Mobile is unchanged. Cookie-auth admin surface is unchanged outside
the new Inbound activity page (read-only) and the token-create
modal extensions for source_system + inbound_resources scope.
Outbound webhook dispatcher (v1.6) and polling endpoints (v1.5) are
unchanged. License changed from MIT to Apache 2.0 with this release;
pre-v1.7.0 tagged releases remain MIT-licensed.
Inbound API surface:
- Five POST endpoints under
/api/v1/inbound/(#253-#257). One per canonical resource. Shared 10-step handler covering external_id + external_version validation, advisory-lock on(source_system, external_id)to serialize concurrent upserts on the same key, stale-version 409, mapping-doc apply, canonical INSERT-or-UPDATE, cross_system_mappings registration, source_payload staging, and audit_log on terminal state. Every response carriesX-Sentry-Canonical-Model: DRAFT-v1. 422 on body validation failure, 413 onContent-Length > SENTRY_INBOUND_MAX_BODY_KB, 409 on stale_version / cross_system_lookup_miss / lock_held. GET /api/v1/inbound/mapping-schema(#251). Unauthenticated documentation aid emitting JSON Schema (Draft 2020-12) for offline validation ofdb/mappings/<source_system>.yaml. Cacheable viaCache-Control: public, max-age=300.- Cross-direction + per-resource scope on
@require_wms_token(#252). Inbound POST routes use the newinbound_resourcesarray (Decision-S; separate fromevent_types). Cross-direction tokens refused with 401cross_direction_scope_violation; in-scope token but resource not in the array returns 401inbound_resource_scope_violation. Empty array denies.
Mapping document format:
- Strict-typed YAML loader (#248-#250). Pydantic with
extra='forbid'; JSONPath viajsonpath-ng; derived expressions viasimpleevalwith a function whitelist (int,float,str,len,abs,min,max,round); attribute walks and__import__/eval/execrejected. Cross-system lookup misses on required-true fields raise 409 carrying the missing(source_system, source_type, source_id)tuple. simpleeval pinned at 1.0.5. boot_loadwrites oneMAPPING_DOCUMENT_LOADaudit_log row per loaded doc carryingsource_system,path,sha256,mapping_version,version_compare,resource_count. Boot refuses to start when an allowlisted source has no doc OR a doc has no allowlist row.- Operator-facing template (#280) at
db/mappings/example-template.yaml.template. Annotated YAML covering all five resources with every required canonical column markedrequired: trueplus comments naming the schema constraint, every supportedtype:(string / integer / decimal / boolean / uuid / iso_timestamp / enum), all threeversion_comparestrategies,cross_system_lookupexamples onsales_orders.customer_idandpurchase_orders.vendor_id, and a footer block listing common pitfalls. The.templatesuffix excludes it fromboot_loadso it is documentation-only.
Admin panel:
- Token-create modal extensions (#258). Issuance surface gains
source_system(dropdown sourced frominbound_source_systems_allowlist) andinbound_resources(multi-select). Existingevent_types,endpoints, andwarehouse_idsstay independent so a token can be outbound-only, inbound-only, or both. Themapping_overridecapability checkbox is present but the v1.7.0 handler rejects requests carryingmapping_overridesregardless (#269). - Inbound activity page (#259). Read-only admin page listing
recent inbound rows joined to issuing token + source_system +
canonical resource. Filters by source_system, resource, status
(
accepted/stale_version/lookup_miss), and time range. Per-row drilldown shows the stagedsource_payloadJSON, the resolvedcanonical_payload, and the audit_log entry from the upsert.
Retention + cleanup:
source_payloadretention beat task (#260). Celery beat task NULLs out the stagedsource_payloadJSONB column pastSENTRY_INBOUND_SOURCE_PAYLOAD_RETENTION_DAYS(default 90 days).inbound_cleanup_runslog table records every run for operator audit. 7-day hard floor at boot (V-201 shape) refuses to start the api on a typo'd or zero retention. Migration 045 makessource_payloadnullable so the retention task NULLs rather than DELETEs (preserves cross_system_mappings + canonical FKs).
Pre-merge gate fixes:
- mapping_overrides hard reject (#269). Reserved for v1.7.1 pending the source-path-remap-vs-canonical-value-replacement semantics decision (#270).
- audit_log chain serialization (#271). Pre-#271 the V-025
chain trigger read
prev_hashwithout serialization; concurrent inserts forked the strict-by-log_id chain. Final form (migration 047): drop theBIGSERIALDEFAULT, addaudit_log_chain_headsentinel, replace the trigger to acquireLOCK TABLE EXCLUSIVE MODEand assignNEW.log_id := nextval(...)inside the critical section. Two earlier iterations (pg_advisory_xact_lock, thenSELECT FOR UPDATEon a sentinel) did not hold under READ COMMITTED + BIGSERIAL DEFAULT timing. Seedocs/audit-log.mdfor the invariants. - Boot eval-shape rejection of mapping docs (#272). Static AST
walker rejects derived expressions whose AST contains forbidden
names, attribute walks rooted off
source, or call targets outside the function whitelist. Single-sourced helper called from both apply-time and boot-time so a malicious expression in a never-reached branch cannot sit dormant in a loaded doc. SENTRY_INBOUND_MAX_BODY_KBboot validator (#273). Pre-fix silently clamped to[16, 4096]and silently fell back to 256 on parse failure. Boot guard now refuses out-of-range or unparseable values; runtime helper trusts the boot guard rather than re-clamping.- Direct-DB revoke propagates auth invalidation (#274, #278).
AFTER UPDATE OF
revoked_attrigger firespg_notify('wms_token_revocations', token_id::text)and (#278) flipsstatusto'revoked'in lock-step. New daemon thread inservices.token_cacheLISTENs and calls_invalidate_token_id_localon receipt.auth_middleware.pyadds a defense-in-depth second 401 gate rejectingrevoked_at IS NOT NULLregardless of status. Independent of Redis; sub-second cross-worker invalidation for direct-DB revokes. - Allowlist TRUNCATE forensic trigger reachability (#275).
Documented as TRUNCATE-CASCADE-only (plain TRUNCATE raises
ForeignKeyViolationbefore the trigger fires; CASCADE is the sole forensic-write path). SENTRY_INBOUND_MAPPINGS_DIRdefault (#279). Changed from relativedb/mappings(resolved from CWD/appto/app/db/mappings, which silently ignored docs at the documented repo-root path) to absolute/db/mappingsmatching the./db:/dbCompose volume mount. Operator-facing template moved todb/mappings/example-template.yaml.template.TEST_DATABASE_URLhard-fail in conftest (#265). Pytest now refuses to run unlessTEST_DATABASE_URLis set and distinct fromDATABASE_URL; the conftest TRUNCATEs 39 tables at session start and v1.7 added operator-managed state where that wipe was a real footgun. CI workflow already provisions and forwards both vars.
Hygiene + tooling:
- CI lint suite for v1.7.0 inbound (#262). New
test_inbound_ci_lints.pycovers no eval/exec/__import__inmapping_loader.py, every loaded mapping doc declares a validversion_compare, mappings dir reachable from CI's path resolution. Plus OpenAPI parity test againstservices.inbound_openapi.build_inbound_openapi(). tools/scripts/regenerate-inbound-openapi.py --checkmode (#276). Default writes the YAML in place;--checkexits non-zero on drift with a unified diff naming the regen command. Wired into.github/workflows/test.ymlas a fast-fail step.- k6 load test for the inbound burst (#277). Operator runbook
at
docs/loadtest.md; script attools/loadtest/inbound_v1_7.js. Operator-run, not CI-default.
Migrations:
- 037-038 --
wms_tokensinbound columns +inbound_source_systems_allowlist cross_system_mappingswith audit and DELETE / TRUNCATE forensic triggers.- 039-043 -- One staging table per inbound resource. New
canonical
customersandvendorstables (UUID PK, denormalized) for resources without a v1.5 canonical home. - 044-045 --
inbound_cleanup_runslog table;source_payloadmade nullable so the retention beat task NULLs rather than DELETEs. - 046 --
sales_orders.billing_address+shipping_addresscolumns so the gate-test mapping resolves cleanly. - 047 -- audit_log chain serialization fix.
- 048 --
wms_tokensAFTER UPDATE OFrevoked_attrigger (pg_notify+ lock-stepstatusflip).
All twelve are small DDL operations against new or existing tables. Operators applying v1.7.0 to a v1.6.x deployment apply 037-048 in numeric order before bringing the new compose stack up.
Breaking changes:
TEST_DATABASE_URLrequired forpytest(#265).SENTRY_INBOUND_MAPPINGS_DIRdefault changed to absolute/db/mappings(#279).SENTRY_INBOUND_MAX_BODY_KBboot-validated (#273) -- typo'd values fail boot rather than silently clamping.
Reserved for v1.7.1: mapping_overrides capability (#269; see #270).
Known limitations:
sales_orders.billing_address+shipping_addressare DB-only in v1.7.0 (#268). Rollout to CSV exports, admin panel, and outbound webhook envelopes lands in a follow-up.- No mapping-doc hot-reload; edits require api restart. Each
restart writes a
MAPPING_DOCUMENT_LOADaudit row carrying the file's sha256 so investigators can correlate.
License: changed from MIT to Apache 2.0 with this release.
Pre-v1.7.0 tagged releases remain MIT-licensed; v1.7.0 and later
are Apache 2.0. See LICENSE
and NOTICE.
v1.6.1 -- Webhook Security Patch
2026-05-03. Full notes.
Security patch closing 22 findings (V-300 through V-321) from the post-v1.6.0 audit on the new outbound webhook surface. The audit applied a webhook-classes lens (SSRF, signature timing, retry-storm amplification, secret-rotation race windows, DLQ poisoning, replay-batch amplification, downstream consumer trust boundaries, cross-worker pubsub integrity) plus the v1.5.1 21-class regression check; 22 findings landed and every one is fixed in this release. No deferrals. No API contract changes. Mobile is unchanged.
Three new migrations (034-036). Five new env vars
(SENTRY_PUBSUB_HMAC_KEY, DISPATCHER_HTTP_CONNECT_TIMEOUT_MS,
DISPATCHER_HTTP_READ_TIMEOUT_MS,
DISPATCHER_REPLAY_BATCH_GLOBAL_BUDGET,
DISPATCHER_REPLAY_BATCH_GLOBAL_WINDOW_S). The cookie-auth admin
surface is unchanged outside the response-body fields surfaced by
the replay-batch breakdown and the new hint field on PATCH
responses for paused-by-ceiling subscriptions.
Tombstone gate (chained pair):
- URL canonicalization on the tombstone gate (#218). Pre-fix the
URL-reuse gate matched on the raw
delivery_url_at_deletecolumn; one-character casing or default-port mutations bypassed the gate without supplyingacknowledge_url_reuse. Newcanonicalize_delivery_urlhelper is the single source of truth. Migration 034 addsdelivery_url_canonical, backfills via a PL/pgSQL twin of the helper, and swaps the partial unique index over to the canonical column. - PATCH endpoint runs the tombstone gate on
delivery_urlchange (#219). Pre-fix the PATCH path validated a new URL against scheme + the dispatch-time SSRF guard but did NOT consultwebhook_subscriptions_tombstones. Shared_check_url_tombstonehelper now called from both POST and PATCH;acknowledge_url_reuseis accepted onUpdateWebhookRequest.
HMAC + secret material:
SecretMaterialrefuses pickle (#220). The default__slots__pickle path serialized_plaintextverbatim. Override__reduce_ex__,__reduce__,__getstate__, and__setstate__so multiprocessing IPC, joblib, APM local-capture, and shelve all surface loudly.- Single-serialization runtime check raises
SingleSerializationViolationinstead ofassert(#221). Python-Ostrips assertions; production deployments under that flag lost thebody == signed_body_for_assertiondefense silently. Replaced with an explicitraiseso the check is emitted bytecode regardless of optimization level. - Secret-rotation race closed via
SELECT FOR SHARE(#225). Concurrent rotation could demotegen=1togen=2between the dispatcher's read and its sign + send.FOR SHAREserializes against rotation; the row's actualgenerationis projected into the returnedSecretMaterialand stamped ontowebhook_deliveries.secret_generationbefore the HTTP send.
Cross-worker pubsub integrity:
- HMAC-signed
webhook_subscription_eventsenvelope (#227). SECURITY.md explicitly assumes Redis may be compromised; the pre-fix channel accepted unauthenticated JSON, so an attacker with publish rights could forgeevent="deleted",event="secret_rotated", orevent="delivery_url_changed". Newpubsub_signingmodule ownsload_key/sign/verify/build_envelope/parse_envelopekeyed onSENTRY_PUBSUB_HMAC_KEY; subscriber verifies viahmac.compare_digestbefore enqueueing. Boot guard refuses api and dispatcher boot on unset / placeholder / short keys.
Replay-batch hardening:
- Pre-INSERT
pending_ceilingcheck (#222). Auto-pause indeliver_oneonly fires AFTER a delivery attempt; replay-batch INSERTed N pending rows in one statement BEFORE any attempt, sidestepping the rail. Refuse 409 with structuredcurrent_pending/impact_count/pending_ceiling/gapfields. SELECT FOR UPDATEon the replay-batch subscription row (#223). Two HTTP requests racing each other could both pass the 60-second per-subscription throttle SELECT before either committed its audit row.FOR UPDATEserializes concurrent replay-batches on the same subscription.- Aggregate (cross-subscription) replay-batch throttle (#224).
The per-subscription bucket was bypassable by a factor of N for a
compromised admin who creates N subscriptions all pointing at the
same consumer URL. New global throttle counts every
WEBHOOK_DELIVERY_REPLAY_BATCHaudit_log row across the deployment in a rolling window; defaults 5 batches per 5 minutes. - Replay-batch reports matched-but-pruned count breakdown
(#233). The impact COUNT used a LEFT JOIN to
integration_eventsso rows whose underlying event was pruned silently disappeared from the count. Surfacematched_with_event_data(replayable) +matched_without_event_data(pruned) on both the response body and audit_log details.
HTTP client:
- Response body buffering capped at 64KB (#226).
session.postran withoutstream=True, so a malicious consumer that streamed a multi-GB 5xx body spiked worker RSS by gigabytes per delivery. Passstream=True, close in a finally block, and refuse oversized advertisedContent-Lengthup front. - Tuple HTTP timeouts + wall-clock watchdog (#237). A consumer
dripping one byte every 9 seconds under a 10s read timeout could
keep the connection alive forever. Pass timeout as
(connect, read)and wrap the call with a thread watchdog enforcing a hard wall-clock cap. Two new env varsDISPATCHER_HTTP_CONNECT_TIMEOUT_MS(5000) +DISPATCHER_HTTP_READ_TIMEOUT_MS(8000); env_validator boot guard refuses configurations where either per-op cap exceeds the wall-clock cap.
Subscription state propagation + filter validation:
- PATCH publishes
subscription_filter_changedon filter mutation (#229). New cross-worker kind appended on filter mutation. Filter changes stay non-retroactive: events committed before the PATCH that match the new filter but not the old do NOT re-deliver. Operators backfilling reach for the replay-batch endpoint. - PATCH publishes
ceiling_changedand surfaces a non-resume hint (#230). When the operator lifts the ceiling that paused the subscription but does NOT also flipstatus=active, the response carries ahintfield naming the follow-up step. Resume stays an explicit operator decision. - Empty
subscription_filterarray refusal (#231).subscription_filter={"event_types": []}looked like "deliver no events" but actually meant "deliver every event": filter clauses are truthy-gated on each list field. New_reject_empty_filter_arrayshelper called from POST and PATCH refuses with 400empty_filter_array. - Malformed
subscription_filterfails closed (#232). Pre-fix, a Pydantic parse failure on the JSONB column logged WARNING and fell back toSubscriptionFilter()(matches every event). For an authorization-shaped column this was fail-OPEN. Now fail closed: the dispatcher auto-pauses withpause_reason='malformed_filter', writes aWEBHOOK_SUBSCRIPTION_AUTO_PAUSEaudit_log row, and backs off.
Cleanup, forensic triggers, CHECK constraints:
cleanup_webhook_deliverieschunked deletes (#228). The 6-hour beat task issued a single DELETE that could span tens of millions of rows in one transaction at sustained 50 events/sec, holding a long lock and starving autovacuum. Switched to chunked DELETE with COMMIT between batches; default chunk 1000, default 10-minute wall-clock cap.webhook_deliveriesDELETE/TRUNCATE forensic triggers (#235). Migration 035 mirrors the V-157 / migration 032 shape onwebhook_deliveries. Newwebhook_deliveries_audittable, statement-level AFTER DELETE + AFTER TRUNCATE triggers. Brings v1.6 to parity with the v1.5.1 forensic posture.webhook_subscriptions.status+pause_reasonCHECK constraints (#236). Migration 036 adds enums forstatusandpause_reason. Pre-fix asymmetry: migration 030 had CHECK enums onwebhook_deliveries.statusbut migration 029 left the same column onwebhook_subscriptionsto application validation.
Retry storm + boot validation + docs:
- +/-10% jitter on every retry slot (#234). Pre-fix the retry
schedule was deterministic, so N subscriptions whose first
delivery to the same consumer URL failed at the same minute then
retried at the same minute on every retry slot. Apply +/-10%
jitter using
secrets.SystemRandom; cumulative worst-case still under 17h. - API container runs
dispatcher_env.validate_or_die(#238). validate_or_die ran ONLY in the dispatcher container pre-fix. The api container reads the same dispatcher env vars for admin-endpoint enforcement and the cross-worker pubsub publisher, but a typo'd or out-of-range value never tripped a boot guard there. Wire validate_or_die intocreate_app()aftervalidate_pepper_configand before blueprint registration. - Consumer secret-handling guidance in
docs/api/webhooks.md(#239). New "Handling the secret bytes" subsection covers secret-manager storage, never-commit / never-log, and the pickle / shelve / joblib / APM / debugger leak surfaces consumers commonly do not think about. Symmetric with the server-side gap V-302 closed.
Migrations: 034 adds
webhook_subscriptions_tombstones.delivery_url_canonical + PL/pgSQL
backfill + partial unique index swap. 035 adds
webhook_deliveries_audit + statement-level DELETE / TRUNCATE
triggers. 036 adds CHECK constraints on
webhook_subscriptions.status and pause_reason; ships AFTER
V-314 so malformed_filter is in use before the constraint locks
it down. All three are small DDL operations, BEGIN/COMMIT-wrapped
per V-213.
Operator notes: SENTRY_PUBSUB_HMAC_KEY is required when the
dispatcher is enabled; both api and webhook-dispatcher containers
must receive the same value (docker-compose forwards it). Generate
with python -c "import secrets; print(secrets.token_hex(32))".
DISPATCHER_ENABLED=false bypasses the boot guard so a
kill-switched deployment can come up without the key.
DISPATCHER_HTTP_CONNECT_TIMEOUT_MS (5000) +
DISPATCHER_HTTP_READ_TIMEOUT_MS (8000) must each be <=
DISPATCHER_HTTP_TIMEOUT_MS (10000, the wall-clock cap).
DISPATCHER_REPLAY_BATCH_GLOBAL_BUDGET (5) +
DISPATCHER_REPLAY_BATCH_GLOBAL_WINDOW_S (300) tune the aggregate
replay-batch throttle and are operator-only. No mobile APK ships
with v1.6.1; existing v1.5.1 APKs on Chainway C6000 devices
continue to work. Standard upgrade procedure applies: git pull
&& docker compose down && docker compose build && docker compose
up -d.
v1.6.0 -- Outbound Push (Pipe A Write)
2026-04-30. Full notes.
Push-delivery counterpart to v1.5.0's polling read. External systems
no longer have to long-poll integration_events: a new
sentry-dispatcher daemon reads each visible event and POSTs it to
admin-registered consumer URLs over HMAC-signed HTTPS, with
exponential-backoff retries, a 1,000-row dead-letter lane, and
admin-panel CRUD + DLQ triage + replay. Builds on the v1.5.0 outbox
and the v1.5.1 hardening pattern: every architectural choice that
drove a v1.5.1 audit finding is pre-empted here at the top of the
branch (strict-typed Pydantic filters, env-var combination guards,
Redis-pubsub cross-worker invalidation, dedicated least-privilege DB
role, audit_log writes at every admin mutation, DELETE / TRUNCATE
statement-level forensic triggers, BEGIN/COMMIT-wrapped migrations).
Mobile is unchanged; no new APK ships. Admin panel gains a Webhooks page and a wired global search bar (the placeholder TopBar input that has been a non-functional stub since v1.4 #163).
Subscription data model + forensic triggers:
webhook_subscriptions+webhook_secrets(migration 029). UUID PK on subscriptions so admin URLs are not enumerable; per-subscriptionrate_limit_per_second+pending_ceiling+dlq_ceilingcolumns with CHECK bounds; secrets are Fernet-encrypted withSENTRY_ENCRYPTION_KEYand live at(subscription_id, generation)PK withgeneration IN (1, 2)for the dual-accept rotation pattern.webhook_deliveries(migration 030). Append-only per attempt with one exception: the terminaldlqtransition flips the same row that was lastin_flight.ON DELETE RESTRICTfromsubscription_idso a hard delete with live deliveries fails; soft-delete (status='revoked') is the supported path. Four partial indexes cover the dispatcher and admin hot paths.integration_eventsNOTIFY trigger (migration 031). The v1.5 deferred-constraint trigger UPDATEsvisible_atat COMMIT; this migration adds an AFTER UPDATE trigger that firespg_notify('integration_events_visible', event_id)so the dispatcher's LISTEN thread wakes within ~10ms of commit. 2-second fallback poll runs always so a missed NOTIFY costs at most one poll cycle.webhook_subscriptions_audit+webhook_secrets_audit(migration 032). Inherits the V-157 wms_tokens forensic-trail pattern from day one: every DELETE / TRUNCATE on either table appends a row capturingevent_type,rows_affected,sess_user,curr_user,backend_pid,application_name,event_at.webhook_subscriptions_tombstones(migration 033). Hard-delete writes a tombstone capturingdelivery_url_at_delete; a subsequent CREATE under the same URL returns 409url_reuse_tombstoneuntil the admin acknowledges withacknowledge_url_reuse: true. Mirrors v1.5.1 V-207 for consumer-groups.
Dispatcher daemon:
- New
sentry-dispatcherCompose service. Synchronous psycopg2 - ThreadPoolExecutor +
requests; mirrors the v1.5 snapshot-keeper shape. One worker thread per active subscription, refreshed every 60s, withverify=Truealways andallow_redirects=Falseso a malicious consumer cannot bounce traffic to an internal target via 3xx. - LISTEN/NOTIFY wake + 2s fallback poll + Redis pubsub subscriber.
Three sources merge into one in-process queue. Cross-worker
invalidation events (
paused,resumed,deleted,delivery_url_changed,rate_limit_changed,secret_rotated) flow on thewebhook_subscription_eventschannel; the §2.9 action table documents which combination of subscription-list eviction, session teardown, DB refresh, and rate-limit-bucket re-init each event triggers. - Per-subscription delivery loop. Cursor-based; advances strictly
on terminal state (
succeededordlq). Head-of-line blocking is intentional per plan §2.5 (silent skip-ahead is worse than visible growing lag). Hard-coded retry schedule[1s, 4s, 15s, 60s, 5m, 30m, 2h, 12h]-- eight attempts, DLQ on the eighth, ~15h cumulative window. - Per-subscription pending and DLQ ceilings auto-pause the
subscription atomically with the ceiling-th write; per-subscription
override is constrained to the deployment-wide hard cap
(
DISPATCHER_MAX_PENDING_HARD_CAP,DISPATCHER_MAX_DLQ_HARD_CAP), which is env-var-only so an admin who can pause cannot also disable the safety ceiling. - Dispatch-time SSRF guard with DNS-rebinding mitigation
invariant. Every POST resolves
delivery_urlviasocket.getaddrinfoand rejects RFC1918, loopback, link-local, IMDS, IPv6 ULA + AWS IMDSv2. Subscription mutations that change the resolved network destination force fresh DNS resolution on the next dispatch via session teardown.SENTRY_ALLOW_INTERNAL_WEBHOOKS=truebypasses the check in dev / CI; production refuses to boot. The combinationSENTRY_ALLOW_HTTP_WEBHOOKS=true + SENTRY_ALLOW_INTERNAL_WEBHOOKS=truerefuses to boot regardless ofFLASK_ENV. - Dedicated least-privilege Postgres role via
db/role-dispatcher.sql. Operators setDISPATCHER_DATABASE_URLto point at the role; dev / single-role deployments leave it unset and the dispatcher falls back toDATABASE_URL. A compromise of the dispatcher cannot readusers,wms_tokens, or any table outside the narrow grant set.
HMAC signing + 24-hour dual-accept rotation:
- HMAC-SHA256 over the canonical signing input
f"{X-Sentry-Timestamp}.{body}"wherebodyis the exact request bytes the dispatcher serialized once. Three layers of enforcement on the single-serialization invariant: a CI lint that forbids more than onejson.dumpscall on the envelope underwebhook_dispatcher/, a runtime assertion at the HTTP-client boundary that fails loudly if any code path introduces a transformation between sign and send, and an integration test that fires the assertion when a transformation is introduced. Constant-time signature comparison everywhere (hmac.compare_digest); CI lint forbids==on signature bytes. - 24-hour dual-accept rotation. Each subscription has two secret
slots:
generation=1(primary, what the dispatcher signs with),generation=2(previous, valid for 24 hours after rotation). Plaintext returned exactly once at issuance / rotation; never echoed inrepr(); never written toaudit_log.details. - 5-minute replay-protection window documented as the consumer
contract: the verifier rejects any request whose
X-Sentry-Timestampis more than 5 minutes from the consumer's wall clock (bidirectional). Bounds the value of a captured request to a 5-minute replay window even with a valid signature.
Admin webhooks surface:
/api/admin/webhooksCRUD with one-shot plaintext secret on create, server-side validation thatconnector_id, everyevent_typesentry, and everywarehouse_idsentry exists, HTTPS-onlydelivery_urlpolicy with a documented opt-out, ceiling enforcement against the deployment hard caps, URL-reuse tombstone gate, andaudit_logwrites at every mutation site.- PATCH publishes the matching cross-worker pubsub event after
commit (
paused,resumed,delivery_url_changed,rate_limit_changed); status transitions out ofrevokedare refused. DELETE soft-deletes by default;?purge=truehard-deletes with tombstone (cascades through terminalwebhook_deliveries; refused while live deliveries reference the subscription). - DLQ viewer paginated and joined to
integration_eventsso the operator reads what payload failed without a second round-trip. Replay-one inserts a freshpendingrow pointing at the originalevent_id(URL-tampering check rejects mismatcheddelivery_id). Replay-batch with filter, server-computed impact estimate, 10,000-row hard cap (overrideDISPATCHER_REPLAY_BATCH_HARD_CAP) requiringacknowledge_large_replay: true, and a 60-second per-subscription throttle tracked throughaudit_logso a missed-trigger restart cannot reset the timer. - Per-subscription stats endpoint (
?window=1h|6h|24h|7d) with attempts / succeeded / failed / dlq / in_flight / pending counters, p50/p95/p99 response_time_ms, top 5 error_kinds, and current cursor lag. 30-second in-process cache. - Cross-subscription error log.
GET /api/admin/webhook-errorsjoins delivery failures (status infailed/dlq) to the server-owned error catalog at response time; the consumer's response body is intentionally NOT stored.webhook_deliveries.error_detailcarries only categorical short messages fromapi/services/webhook_dispatcher/error_catalog.py. Pre-design the dispatcher capturedresponse.text[:512]directly into the column; a misconfigured consumer endpoint can echo upstream credentials (database connection strings, API tokens, session cookies) into a 5xx page, and persisting that body would make the DLQ admin viewer a credential-exfiltration channel for the consumer's secrets. The catalog coverstimeout,connection,tls,redirected,4xx,5xx,ssrf_rejected,unknown. - React admin Webhooks page with subscription list (status badge, last-24h success rate, current pending count), create wizard (connector picker, HTTPS-validated URL, scope-catalog checkbox filter builder, rate-limit + ceiling sliders, one-shot secret reveal modal with saved-secret acknowledgement, URL-reuse warning modal), per-row actions (edit / pause-resume / rotate / DLQ / stats / revoke / purge), DLQ panel with replay-one + replay-batch (server-computed impact estimate inline; 429 throttle response surfaces the countdown), stats panel, and a cross-subscription "View errors" panel with row expansion showing the catalog description and triage hint.
Admin global search bar (#163, carry-forward from v1.4):
GET /api/admin/search?q=&warehouse_id=. Single endpoint fanning out across items, bins, purchase_orders, sales_orders, and the denormalized customer columns on sales_orders. Per-type cap of 10 rows, total cap of 50, minimum query length 2 to avoid worst-case wildcard scans. Items are global; bins / POs / SOs / customers are filtered to the supplied warehouse_id.- TopBar dropdown wiring + list-page
?q=prefill. The TopBar input that has been a non-functional placeholder since v1.4 now drives the new endpoint with a 250ms debounce and a dropdown that follows the existing warehouse-picker shape (click-outside dismisses, Arrow keys + Enter + Esc). Selection routes to the matching list page; the four list endpoints (items, bins, POs, SOs) gained?q=ILIKE support.
Hygiene + CI guardrails:
- Celery beat cleanup.
cleanup_webhook_deliveriesenforces 90-day retention on terminalwebhook_deliveriesrows (every 6h);cleanup_expired_webhook_secretsdrops gen=2 rows past their 24hexpires_at(hourly). - CI guardrails consolidation. Single workflow gate covers no
verify=Falseanywhere underwebhook_dispatcher/(extended in this release to includehttp_client.py); no doublejson.dumpson the envelope; sentinel grep that thebody == signed_bodyruntime assertion stays present at the HTTP-client boundary; audit_log coverage check asserting every webhook admin mutation writes aWEBHOOK_*row. - Integration test matrix.
test_v160_integration_matrix.pymaps each of the 26 verification-plan points to a real test function or to an operator-manual gate logged viacaplog. The Chainway C6000 smoke test is the one operator-manual gate; everything else is automated. 1528 backend tests passing (up from 910 at v1.5.0, 1002 at v1.5.1).
Migrations:
- 029 --
webhook_subscriptions+webhook_secrets. UUID PK, JSONB filter, ceiling columns with CHECK bounds, partial index on active status. BEGIN/COMMIT-wrapped per v1.5.1 V-213 discipline. - 030 --
webhook_deliveries. BIGSERIAL PK, RESTRICT FK on subscription_id, four partial indexes covering dispatcher and admin hot paths. - 031 -- AFTER UPDATE trigger on
integration_events.visible_atthat firespg_notify('integration_events_visible', event_id). Self-test asserts the deferred-trigger -> UPDATE -> AFTER-UPDATE-trigger -> NOTIFY chain holds under a single outer commit. - 032 --
webhook_subscriptions_audit+webhook_secrets_audittables with statement-level DELETE / TRUNCATE triggers on both parent tables. - 033 --
webhook_subscriptions_tombstonestable for the URL-reuse acknowledgement gate.
Notes for operators:
- Existing v1.5.x deployments must apply migrations 029-033 in numeric order before bringing the new compose stack up; the dispatcher container's startup queries against the new tables fail until they exist. Fresh installs run them automatically. CI verification of the upgrade path lands in v1.7 (#217); until then the operator runs the migration sequence manually as part of the upgrade.
SENTRY_ENCRYPTION_KEYnow protects two ciphertext stores:connector_credentials(v1.3 inbound vault) andwebhook_secrets(v1.6 outbound HMAC). Fernet rotation must re-encrypt both in the same transaction; missing one leaves a half-rotated deployment where the affected service cannot decrypt its own secrets after restart. See the updated rotation section indocs/connectors.md.DISPATCHER_DATABASE_URLis optional. Dev and single-role deployments leave it unset. Production should set up a dedicated least-privilege role viadb/role-dispatcher.sqland pointDISPATCHER_DATABASE_URLat it.DISPATCHER_ENABLED=falseis the kill switch. Container boots, logs CRITICAL, sleeps with the heartbeat file still touched. Use it to stop dispatch globally without a code rollback.- No mobile APK ships with v1.6.0. v1.6.0 has no mobile code
changes beyond the version-string bumps for BUILD_VERSION-guard
consistency. Operators already on the v1.5.1 APK
(
sentry-wms-v1.5.1.apk) should stay on it -- it carries the dep-tree security overrides from #158 and #61. Operators still on older v1.4.1 / v1.4.3 APKs continue to authenticate and dispatch but lack those security fixes; install v1.5.1 if you have not already.
v1.5.1 -- Security Audit Patch
2026-04-27. Full notes.
Security patch closing ~22 findings from the post-v1.5.0 internal audit
of the Outbound Poll attack surface: the X-WMS-Token vault, the
/api/v1/events* and /api/v1/snapshot/* endpoints, the
integration_events outbox, the snapshot-keeper daemon, and the admin
token / consumer-group / connector-registry CRUD pages. No new
features. No API contract changes. No mobile runtime changes (the APK
is a fresh artifact only because the dependency overrides reshape the
build tree). Existing well-formed clients with correctly-scoped tokens
see no behaviour difference; what changed is enforcement strictness.
Token auth fixes:
- Endpoint scope is now actually enforced (#140). Pre-fix the
endpointscolumn onwms_tokenswas stored and rendered in the admin UI but@require_wms_tokennever consulted it; a token with any-or-no endpoint list could hit every/api/v1/*route the warehouse / event-type scope allowed. Migration 026 backfills pre-existing empty arrays so old tokens keep working. - Cross-worker token revocation via Redis pubsub (#146). Pre-fix
token_cache.clear()only flushed the handling gunicorn worker's dict; every other worker honored the stale entry until per-entry TTL expired (up to 60s). v1.5.1 publishes revocations on awms_token_eventschannel that every worker subscribes to at boot. Sub-second across all workers in the Redis-available path; the 60s TTL remains as the backstop when Redis is down. - Stricter pepper validation (#142). Boot guard rejects unset,
empty, whitespace-only, the
.env.exampleplaceholder, and any value shorter than 32 characters. Pre-fix it rejected only unset / empty. - Uniform
401 invalid_tokenbody (#149). Pre-fix the decorator returned three distinct bodies (missing / invalid / expired); an attacker who captured a plaintext could distinguish "this was once valid" from "never valid." Specific reason now stays in DEBUG log onsentry_wms.auth.wms_token. - Issuance-time scope existence checks (#150). Admin token
issuance validates that
warehouse_idsandevent_typesactually point at real entities. Unknown values fail 400 with the offending entries enumerated. - Admin CRUD writes the audit_log hash chain (#141, #154).
wms_tokens,consumer_groups, andconnector_registrymutations now append toaudit_logat every site (issue, rotate, revoke, delete). Plaintext tokens never written todetails; delete captures pre-mutation scope so the trail survives row removal. - Checkbox scope selectors on the token-create modal (#159). New
admin endpoint
GET /api/admin/scope-catalogpopulates the warehouse / event-type / endpoint lists.
Polling and snapshot fixes:
/api/v1/events/ackenforces cursor horizon and per-event scope (#143). Pre-fix a token with a legacy admin-issued shape could ack an arbitrary cursor on any consumer_group, jumping the cursor past every future event and silently losing data downstream. Now returns400 cursor_beyond_horizonand403 ack_scope_violationon the failing shapes; backwards acks remain pure no-ops.- Per-token concurrent-scan cap on
/api/v1/snapshot/inventory(#144). A single token could pin the entire 4-slot keeper pool; v1.5.1 caps to one active scan per token. Cursor requests on an active scan are exempt so partial-page flows keep working. - Strict-typed
consumer_groups.subscription(#145). Pydantic withextra="forbid". Belt-and-suspenders parse-error path on the poll handler so legacy bad rows surface409 subscription_invalidinstead of 500. - Consumer-group recreate requires explicit replay acknowledgement
(#148). Migration 027 (
consumer_groups_tombstones) recordslast_cursor_at_delete. CREATE under a deleted id returns409 replay_would_skip_historyunless the admin sendsacknowledge_replay: true. /api/v1/events/typesfilters by token scope (#151). Pre-fix every caller saw every event type known to the system regardless of scope; reconnaissance for a later pivot is no longer free.
Database and infrastructure fixes:
- Migrations 020 + 025 wrapped in transactions (#152). The ten-table ALTER blocks are now all-or-nothing.
- Snapshot-keeper supports a least-privilege DB role (#153). New
SNAPSHOT_KEEPER_DATABASE_URLenv var; falls back toDATABASE_URLwhen unset so dev and single-role deployments are unchanged. Newdb/role-snapshot-keeper.sqlprovisions the role with the narrow grant set (SELECTonintegration_events,SELECT/UPDATE/DELETEonsnapshot_scans,EXECUTEonpg_export_snapshot). - Boot guard on dangerous proxy + bind combination (#147). Refuses
to start with
TRUST_PROXY=trueANDAPI_BIND_HOST=0.0.0.0because the combo lets any caller who reaches the api port directly spoofX-Forwarded-Forand poison every rate-limit bucket, audit attribution, and downstream IP allowlist. Escape hatchSENTRY_ALLOW_OPEN_BIND=1logs CRITICAL on every boot. wms_tokensdeletion forensic trail (#157). Migration 028 ships awms_tokens_audittable plus AFTER DELETE / AFTER TRUNCATE statement-level triggers capturingevent_type,rows_affected,sess_user,curr_user,backend_pid,application_name,event_at. Resolves the unattributed token wipe observed during the v1.5.0 release gate.- Audit catch-all (#156).
proxy_fix_activehidden from anonymous/api/health(moved to admin-gatedGET /api/admin/system-info); dev-only banners ondocker-compose.proxied.ymlandproxy/nginx.conf; ProxyFixx_prefix=0reconciled with inline comment;SENTRY_VALIDATE_EVENT_SCHEMASno longer frozen at module import; external-id CI guardrail walksdb/**/*.sqlin addition toapi/**/*.py. source_txn_idconsumer-dedupe contract documented (#155).docs/events/README.mdnow states explicitly that consumers MUST dedupe onevent_id(server-side BIGSERIAL, monotonic in commit order), not onsource_txn_id(attacker-controllable viaX-Request-ID).- CSP report sink (#54). New unauthenticated
POST /api/csp-reportlogs CSP violations at WARNING, rate-limited 60/min per IP.
Dependency hygiene:
@xmldom/xmldom-> ^0.9.10 override (#158). Closes four newly-disclosed GHSAs against<=0.8.12reachable through five expo-related transitive paths. Build-time only (Expo config plugins). Silences the nightly Dependency Audit onmainthat had been failing since 2026-04-24.- cryptography 44.0.3 -> 46.0.7 (#59). Closes carried-over GHSA-r6ph-v2qm-q3c2 and GHSA-m959-cc7f-wv43. Fernet / MultiFernet compatibility verified across 45.x and 46.x.
- pytest 8.3.4 -> 9.0.3, pytest-cov 6.0.0 -> 7.1.0 (#60). Closes GHSA-6w46-j5rx-g56g; pip-audit allowlist now empty.
- eas-cli dev-tree GHSAs closed (#61).
minimatch ^5.1.9andnode-forge ^1.4.0overrides; eas-cli bumped 18.5.0 -> 18.8.1.npm-audit-mobile-devis now a gating job matching the prod-tree job.
UI defects caught during the audit cycle:
- Recent Adjustments and Recent Transfers tables on the dashboard render every column (#161, #162). Both were clipping a column on narrower viewports.
Migrations: 026 backfills wms_tokens.endpoints for tokens
created before v1.5.1 (idempotent), 027 adds
consumer_groups_tombstones, 028 adds wms_tokens_audit plus the
DELETE / TRUNCATE triggers.
Operator notes: a SENTRY_TOKEN_PEPPER shorter than 32 characters or
set to the .env.example placeholder now fails boot. Existing
well-formed peppers (32+ chars of entropy) hash to the same value and
require no changes. The new APK
(sentry-wms-v1.5.1.apk, attached to the GitHub release) installs
over v1.5.0 on Chainway C6000 devices. Standard upgrade procedure
applies: git pull && docker compose down && docker compose build &&
docker compose up -d.
v1.5.0 -- Outbound Poll (Pipe A Read)
2026-04-22. Full notes.
First /api/v1/* surface. External systems -- ERPs, commerce
platforms, analytics pipelines -- can now consume every
inventory-changing write Sentry performs via a cursor-paginated REST
read. The release ships a transactional outbox, a commit-order
visibility gate, a bulk-snapshot endpoint for the initial load, and
X-WMS-Token auth with hash-only storage. Admin panel gains two new
pages (API tokens, Consumer groups); mobile is untouched.
Outbox + emission:
integration_eventstransactional outbox (migration 020).BIGSERIAL event_id,JSONB payload, denormalizedaggregate_external_id, four btree indexes covering the v1.5.0 query shapes. Deferred-constraintvisible_attrigger setsvisible_at = clock_timestamp()at COMMIT so readers ordering on(visible_at, event_id)see events in commit order even when BIGSERIAL assignedevent_idvalues in a different order.- Seven emissions pinned to the framework catalog:
receipt.completed,adjustment.applied(approval + direct),cycle_count.adjusted,transfer.completed,pick.confirmed(one per SO in a pick batch),pack.confirmed,ship.confirmed. JSON Schema files atapi/schemas_v1/events/<type>/1.jsonvalidated Draft 2020-12. Per-aggregateSELECT ... FOR UPDATEretrofit gives FIFO on the outbox without behaviour change for users. - External UUID retrofit across ten aggregate / actor tables
(
users,items,bins, orders, receipts, adjustments, transfers, counts, fulfillments). Every insert site suppliesuuid.uuid4()explicitly; migration 025 drops theDEFAULT gen_random_uuid()after the retrofit so a new handler that forgets the column fails loudly. - Schema registry + CI validation.
events_schema_registry.pyloads every schema atcreate_apptime; boot fails on a malformed or missing file. A dedicated CI step imports the registry on a fresh checkout so a broken schema fails the job before tests run.
Polling + snapshot endpoints:
GET /api/v1/events-- cursor + consumer-group polling. Plainint64cursor (Decision G: not base64, not opaque), nohas_morefield (full page implies more; partial implies caught up). Mutual exclusion ofafter+consumer_groupreturns 400. Strict-subset scope enforcement (Decision H): a filter asking for anything outside the token's scope returns 403, never a silent intersection.POST /api/v1/events/ack-- consumer-group cursor advance. Atomic UPDATE with alast_cursor <= :cursorguard; out-of-order ack is a no-op, retried ack is idempotent.GET /api/v1/events/typesandGET /api/v1/events/schema/<type>/<version>-- in-process catalog + raw JSON Schema body served asapplication/schema+json.GET /api/v1/snapshot/inventory-- bulk-snapshot endpoint for the initial load, backed by a newsnapshot-keeperdaemon that holds REPEATABLE READ transactions and exports apg_snapshot_idviapg_export_snapshot(). API tier imports the same snapshot on short-lived connections viaSET TRANSACTION SNAPSHOT '<id>'. Keyset-paginated by(warehouse_id, item_id, bin_id)so page cost is O(limit) regardless of scan size.- Per-token rate limits. 120 req/min on polling routes,
2 req/min on the snapshot endpoint. Bucket key prefers
token:<id>overuser:<id>over remote IP so a noisy connector cannot starve interactive cookie users.
Auth + token vault:
wms_tokenshash-only vault (migration 023).CHAR(64)token_hashUNIQUE, typed-array scope columns (warehouse_ids BIGINT[],event_types TEXT[],endpoints TEXT[]), defaultexpires_at = NOW() + INTERVAL '1 year'. Noencrypted_tokencolumn -- lost plaintext means rotate, matching the GitHub / Stripe / AWS standard.SENTRY_TOKEN_PEPPERenv var.token_hash = SHA256(pepper || plaintext).hex(). Pepper is env-only (never in the DB), required at boot. Rotating it is an emergency-only control that invalidates every issued token at once; runbook atdocs/runbooks/token-pepper-rotation.md.@require_wms_tokendecorator + per-worker 60s TTL cache. Applied only to/api/v1/events*and/api/v1/snapshot/*; cookie-auth routes keep@require_auth. Revocation is visible within 60 seconds across every API worker.
Admin panel:
- API tokens page (
/api-tokens) with rotation badges + per-row rotate / revoke / delete actions, one-time plaintext reveal with copy-to-clipboard and a save-confirmation checkbox. - Consumer groups page (
/consumer-groups) with subscription preview + heartbeat freshness, create + edit modals. - Connector registry endpoints under
/api/admin/connector-registry(distinct from the v1.3connector_credentialsvault; the two concepts converge in v1.9).
Migrations: 020 (integration_events), 021 (connectors,
consumer_groups), 022 (credential_type), 023 (wms_tokens),
024 (snapshot_scans + NOTIFY trigger), 025 (drops the
external_id DEFAULT post-retrofit).
Tests: 910 backend passing (up from 740 at v1.4.5, +170 new cases), 58 admin unchanged, 32 mobile unchanged. CI gains a dedicated schema-validation step that imports the registry on every push so a broken schema file fails the job before tests run.
Operator notes:
- First
/api/v1/*surface. This is the outbound read side for Pipe A. Cookie-authed admin/mobile routes under/api/*keep their existing contract. SENTRY_TOKEN_PEPPERis required at boot. Generate withpython -c "import secrets; print(secrets.token_hex(32))"and set it in.envbeforedocker compose up -d. The api container refuses to boot without it. Rotating the pepper invalidates every issued token; seetoken-pepper-rotation.mdfor the procedure.- New
snapshot-keeperservice indocker-compose.yml. After upgrading,docker compose up -dstarts one additional container alongside the existingdb,redis,api,celery-worker, andadmin. The keeper is required forGET /api/v1/snapshot/inventory; a downed keeper surfaces as 503snapshot_keeper_unavailableon the first page of a scan. - No APK update. The v1.4.3 APK on Chainway C6000 devices stays current; v1.5.0 has no mobile code changes beyond the version string in the login / home screen footers.
TRUST_PROXYbehavior unchanged from v1.4.5. Fresh-install operators who run Sentry behind a TLS-terminating reverse proxy setTRUST_PROXY=truein.env; direct-connect deployments leave it unset.
Migration guidance for production deployments (multi-million-row
aggregate tables) lives at
docs/runbooks/v1.5.0-migration.md.
The apartment-lab seed applies all six migrations in seconds; larger
tables should use the documented two-step "add nullable column,
batch backfill, then add UNIQUE + NOT NULL" alternative for
migration 020's external_id backfill.
v1.4.5 -- Reverse Proxy Hotfix Follow-up
2026-04-21. Full notes.
v1.4.4 (#107) wired Werkzeug ProxyFix into api/app.py behind a
TRUST_PROXY env var, but docker-compose.yml was never updated to
pass TRUST_PROXY into the api service environment. Operators who
set TRUST_PROXY=true in .env saw no effect because Compose does
not auto-forward arbitrary host env vars: the value stopped at the
Compose shell and os.getenv("TRUST_PROXY") returned None inside
the container, so ProxyFix stayed off and the CSRF-403-behind-proxy
bug from v1.4.0-v1.4.3 kept firing. Fruxh hit this after installing
v1.4.4 fresh. api + Compose + docs change; admin and mobile untouched.
Fixes:
TRUST_PROXYnow reaches the api container (#136, refs #107, Fruxh's #98).docker-compose.ymlservices.api.environmentgainsTRUST_PROXY: ${TRUST_PROXY:-false}, same pattern asFLASK_ENV. Defaultfalsepreserves the direct-connect posture; operators opt in by settingTRUST_PROXY=truein.env. Without this single line, v1.4.4'sProxyFixwiring was cosmetic for every Compose-deployed install.- ProxyFix state is logged at Flask startup.
api/app.pyemitsProxyFix active: ...orProxyFix inactive: ...at WARNING level so the line clears the default gunicorn stderr threshold. Operators verify withdocker compose logs api | grep ProxyFixwithout execing into the container. /api/healthnow returnsproxy_fix_active. External monitors and the reverse proxy itself can confirm the wiring end-to-end with a single HTTPSGET. A green health response with"proxy_fix_active": falsebehind an nginx deployment is the exact signature of this bug..env.examplegains aTRUST_PROXYblock with the security warning inline, anddocs/deployment.md"Reverse Proxy (HTTPS)" clarifies thatTRUST_PROXYgoes in.envat the repo root (notapi/.env), thatdocker compose restart apidoes NOT re-read.env(usedocker compose up -dto pick up changes), and that the wiring can be verified three independent ways:env | grep TRUST_PROXYin the container,logs api | grep ProxyFixat the Flask layer, andcurl /api/healthfrom outside.
Tests: 740 backend (up from 738 at v1.4.4), 58 admin, 32 mobile.
api/tests/test_proxy_fix.py gains TestHealthEndpointReportsProxyFixState
with two cases locking the /api/health proxy_fix_active contract
in both the unproxied and proxied-client states; the original 4 cases
(opt-in invariant, scheme/host/is_secure rewrite, Secure CSRF + auth
cookies, change-password NOT 403'ing behind proxy) are unchanged and
still green. All CI workflows green.
Operator notes: the v1.4.3 APK is stable; no APK update is needed for
v1.4.5 (mobile has zero code changes and the API contract is
unchanged). Operators who upgraded to v1.4.4 and set TRUST_PROXY=true
but still saw CSRF-403 errors should pull v1.4.5, run docker compose
down && docker compose build && docker compose up -d (NOT just
restart), and confirm the wiring with docker compose exec api env
| grep TRUST_PROXY and curl /api/health.
v1.4.4 -- Reverse Proxy Hotfix
2026-04-21. Full notes.
Every production deployment that fronts Sentry with a TLS-terminating
reverse proxy (nginx, Caddy, Traefik, AWS ALB, etc.) was returning
403 CSRF token missing or invalid on every POST / PUT / PATCH /
DELETE. Fruxh filed #98 from his production install and traced it to
the root cause: Flask's request.host / request.scheme were stuck on
the internal 127.0.0.1:<port> hop, so cookies were scoped to the wrong
host and the browser never resubmitted them. api-only change; admin and
mobile untouched.
Fixes:
- Trust
X-Forwarded-*headers from a reverse proxy whenTRUST_PROXY=true(#107, refs #98).app.wsgi_appis now wrapped in WerkzeugProxyFixwhen the env var is set, sorequest.scheme,request.host, andrequest.is_securereflect the browser's view of the request instead of the internal hop. Opt-in via env var because honouringX-Forwarded-*without a proxy in front lets any client forge its own scheme, hostname, and client IP. Theservices/cookie_auth.pyheader-based fallback stays as belt-and- suspenders. - Reverse-proxy deployment guidance expanded in
docs/deployment.md. NewTRUST_PROXYsection with an annotated nginx config, Caddy and Traefik v2+ snippets, a one-line note covering AWS ALB / GCP HTTPS LB / Azure Application Gateway / Cloudflare Tunnels / Fly / Render, an explicit security warning on header-forgery risk, and a multi-hop section for CDN-in-front deployments. python-dotenvbumped1.0.1->1.2.2(#106) to clearGHSA-mf9w-mj56-hr94. OSV published the advisory between the 2026-04-21 scheduledmainaudit (green) and the v1.4.4 initial push (red). Drop-in compatible; no code changes needed.
Tests: 738 backend (up from 734 at v1.4.3), 58 admin, 32 mobile. New
file api/tests/test_proxy_fix.py (4 cases): the opt-in invariant,
TRUST_PROXY=true rewriting scheme / host / is_secure, login
behind proxy headers returning Secure + SameSite=Strict cookies,
and change-password behind proxy headers NOT 403'ing on the CSRF gate
(Fruxh's exact repro path). All CI workflows green.
Operator notes: the v1.4.3 APK is stable; no APK update is needed for
v1.4.4 (mobile has zero code changes and the API contract is unchanged).
API operators behind a reverse proxy MUST add TRUST_PROXY=true to the
API environment before rebuilding; direct-connect deployments must NOT
set it.
v1.4.3 -- Mobile Patch
2026-04-20. Full notes.
Mobile patch release. Two fixes from the v1.4.3 mobile bug bash, plus a follow-up for a regression surfaced during Chainway C6000 verification. Zero backend or admin code changes. Closes the keyboard-fallback half of Fruxh's #70 report; the camera-scanner half remains tracked under
70 for v2.x.
Fixes:
- Put-away "done" screen no longer overlays the success checkmark on
the title (#103). The done phase was rendered inside a flex
container with
justifyContent: 'center'that also holds a growing session-history list. Once history overflowed the viewport, the centered content pushed the large check glyph visually into the title below it. Swapped to a ScrollView with natural top-down flow, matching the CountScreen done-phase pattern. - Scan input fields now allow keyboard fallback for manual entry and
copy/paste (#104, refs #70).
ScanInputhadshowSoftInputOnFocus={false}andcontextMenuHidden, so tapping a scan field on the Chainway C6000 did nothing and long-press did not expose copy/paste. Removed both. Broadcast-intent scans still route throughScanSettingsContextand bypass the TextInput; keyboard-mode scans still land inonChangeTextthe same way manual typing does. - Scan input soft keyboard now only opens on user tap, not on
auto-refocus (#105). The #104 removal made the 1-second refocus
loop that keeps the field ready for hardware scans re-pop the
keyboard on every tick.
ScanInputnow tracks asoftInputstate that is false by default and flipped to true only ononPressIn, with a forced blur/refocus cycle so the updatedshowSoftInputOnFocusprop applies. Reset on blur and after submit so the auto-refocus loop, mount autofocus, and post-submit refocus stay silent.
Tests: 734 backend, 58 admin, 32 mobile (up from 24; new file
mobile/src/components/__tests__/ScanInput.test.js locks the
tap-to-open contract at the source level since the mobile vitest
harness has no RN runtime). All CI workflows green.
Operator notes: a new sentry-wms-v1.4.3.apk is attached to the
GitHub release and installs over v1.4.1 / v1.4.2 on Chainway C6000
devices without a data wipe. API and admin images have no source
changes; rebuilding them is safe but not required for mobile-only
operators.
v1.4.2 -- Admin Panel Patch
2026-04-20. Full notes.
Admin panel patch release. Operator safeguard against upgrades-without-rebuild, the V-017 validation_error cluster closed on seven admin create/edit forms, admin list page CRUD affordances and UI consistency across every page, plus a bundle of Fruxh-reported fixes from external deployments. Zero mobile code changes; v1.4.3 will follow for mobile-side reports.
Highlights:
- Upgrade-without-rebuild detection (#73) -- v1.4.0 added Flask-Limiter;
v1.3.x operators who ran
git pull && docker compose upwithout rebuilding crashed onModuleNotFoundError: flask_limiter. The API now bakes the source__version__into the image at build time and fail-fast exits 2 with a clear remediation message when the code and image versions disagree.docs/deployment.mdgains an "Upgrading" section. - V-017 validation_error cluster (#74-#81, #99) -- Bin, Zone, PreferredBin, Inventory Adjustment, Inter-Warehouse Transfer, manual PO, manual SO create, Zone edit, plus the pre-merge Bin-create Zone-dropdown fix. Consolidated alignment tests lock every form's payload shape against the backend schema.
- Admin list page CRUD affordances (#85 #86 #87 #88 #89 #90) -- Bin row click opens a detail view with delete; Zone edit gains a delete button with 409-guard when bins are assigned; new dedicated Sales Orders admin list page; Close / Reopen PO and Cancel SO as reversible / one-way state transitions (not deletes).
- UI consistency pass (#102) -- pencil (✎) and trash (🗑) row actions across every admin list page. PO / SO show pencil only; Close / Cancel remain state transitions in the edit modal.
Fruxh-reported from a production v1.4.1 deployment:
#72flask_limiter upgrade crash -- closed by #73.#71validation_error cluster across four admin create forms -- closed alongside #74-#81 and #85.#98First-time-setup "Your session is out of sync" false failure -- closed by the redirect-to-login fix.
Test counts: 734 backend, 58 admin, 24 mobile. All CI workflows (Tests, Dependency Audit, Lockfile Version Check, Deploy Docs) green on the merge commit.
Operator notes: upgrades MUST rebuild Docker images.
git pull && docker compose down && docker compose build && docker compose up -d
is the correct procedure. Skipping the build step now exits 2 at
startup with the remediation command in the logs.
v1.4.1 -- Forced Password Change + Mobile Version Fix
2026-04-18. Full notes.
Patch release bundling two bug fixes deferred from v1.4.0.
Highlights:
- Forced password change on first login (#69) -- fresh installs
seed admin as
admin/adminwith amust_change_passwordflag. Auth middleware blocks every route except/api/auth/me,/api/auth/change-password, and/api/auth/logoutuntil the admin changes the password. Eliminates the "grep logs for the random password" onboarding paper-cut that shipped from v1.0 through v1.4.0. - Mobile version display fix (#68) -- HomeScreen and LoginScreen
had been hardcoding
v1.2.0for two releases. Now read the current version. Issue #67 tracks the v1.5 refactor that eliminates this class of bug permanently via build-time injection. - Forced-mode navigator fix -- mobile
ChangePasswordScreensave spinner stuck bug resolved. React Navigation native-stack was preserving the route whenmust_change_passwordflipped false; removing the screen from the non-forced branch lets native-stack fall through to Home.
Security:
validate_passwordrejectsadminas the new password (case-insensitive, whitespace-stripped).- Mobile force-kill-and-reopen bypass closed: the flag persists inside the SecureStore-backed user dict, so a relaunch rehydrates forced mode.
- Distinct
audit_logactionforced_password_change_completedseparates onboarding completions from voluntary rotations.
Test counts: 690 backend, 42 admin, 24 mobile. All CI green.
Operator notes: fresh installs are prompted to set a new password on first login. Existing installs are unaffected (migration 019 defaults the column to FALSE).
v1.4.0 -- Security Backlog Cleanup
2026-04-18. Full notes.
Pure security and hardening release. No new features. Addresses remaining High-severity items from the v1.3.0 audit, all 9 findings from a fresh audit of the v1.4 work, and the most impactful Medium / Low items from the deferred backlog.
Highlights:
- HttpOnly cookie + CSRF for admin auth (V-045) -- admin JWT no
longer lives in
localStorage. CSRF double-submit pattern protects mutating requests. Mobile continues using bearer tokens. - SecureStore on mobile (V-047) -- JWT migrated from plaintext
AsyncStorage to the Android Keystore via
expo-secure-store. One-shot migration on app launch. - Content-Security-Policy (V-050) -- strict CSP on both API and nginx. Self-hosted fonts eliminate the last third-party origin.
- Sync state race fix (V-102) --
run_idUUID prevents stale workers from clobbering fresh sync state after the 1-hour takeover threshold. - Flask-Limiter rate limiting (V-041) -- Redis-backed, per-user and per-IP quotas on sensitive admin endpoints.
- Dependency audit in CI (V-042) --
pip-auditandnpm auditgate every push. - DNS rebinding pin (V-108) -- connector outbound requests pin the resolved IP after the SSRF guard check.
Test counts: 647 backend, 32 admin, 8 mobile. All CI workflows green.
See the release notes for the full list of V-numbers, the accepted-risk section, and the upgrade notes for admin panel, mobile app, and Docker deployment.
v1.3.0 -- Connector Framework + Security Hardening
2026-04-17. Full notes.
The connector foundation. All the infrastructure for ERP integration without any actual connector -- the framework that NetSuite, BigCommerce, and Amazon connectors will plug into starting in v2.0.
- Abstract base class with auto-discovery registration
- Celery + Redis background job runner so sync operations never block the API thread
- Encrypted credential vault (Fernet, per-warehouse scoping, credentials never in logs or API responses)
- Sync state tracking with green / yellow / red health per connector
- Per-connector rate limiter, exponential backoff with jitter, and 5-failure circuit breaker
Security audit: 4 Critical and 12 High findings fixed before release. Removed hardcoded encryption key default, documented historical JWT secret exposure (SA-2026-001, SA-2026-002), admin panel rebuilt as production nginx, Redis broker requires auth, SSRF protection on connector outbound requests, audit log is now append-only with SHA-256 hash chain, plus IDOR fixes and race-condition fixes on receiving and inventory operations. 570 total backend tests.
Breaking for operators:
SENTRY_ENCRYPTION_KEYis required (no default)REDIS_PASSWORDis required- Admin panel port changed from 3000 to 8080
- Migration
016_audit_log_tamper_resistance.sqlmust be applied
v1.2.0 -- Validation Schemas & Error Boundaries
2026-04-16. Full notes.
- Pydantic v2 validation schemas on every JSON-accepting endpoint (17
schema files).
@validate_bodydecorator for consistent request validation. Invalid requests now return structuredvalidation_errorresponses withtype/loc/msgdetail per field. - Admin panel: every page route wrapped in an independent error boundary so one section crashing no longer white-screens the whole panel. Retry button to recover without a full page refresh.
- Mobile: handles the new
validation_errorformat with operator-friendly messages. - 75 new validation tests + 4 ErrorBoundary tests. 382 backend + 10 frontend tests passing.
v1.1.1 -- Patch
2026-04-16. Full notes.
Three fixes for issues incorrectly closed or missed in v1.1.0. API / admin only, no APK rebuild.
- CSV formula-injection guard on exports (cell values starting with
=,+,-,@,\t,\rare prefixed with a single quote) DATABASE_URLfallback removed (startupRuntimeErrorif unset, same pattern asJWT_SECRET)- Login-attempt count no longer leaked in failed-login error messages
v1.1.0 -- Security Hardening
2026-04-15. Full notes.
Twelve backlog fixes from the v1.0 audit.
- Token invalidation on password change (M1) --
password_changed_atcolumn added; auth middleware rejects tokens issued before the last password change - JWT
iat/jticlaims (L10) -- issued-at and UUID claims for revocation and replay detection - DB-backed rate limiting (M8) --
login_attemptstable, persistent across restarts, per-username and per-IP tracking (5 attempts, 15 min lockout) - Password complexity (L1) -- minimum 8 characters, at least one letter and one digit
- Self-service password change (L2) --
POST /api/auth/change-passwordplus a mobile UI modal in the user dropdown - Warehouse listing auth (L7) --
GET /api/warehouses/listnow requires JWT; mobile warehouse selection moved to a post-login blocking modal suggest_binwarehouse scope (L8) -- preferred-bin and default-bin queries filtered to the user's allowed warehouses- CSV import limit (M10) -- reject payloads over 5000 records
- Cycle count self-approval check (M3) -- configurable
require_count_approval_separationsetting - Pagination (M6) --
page/per_pageon warehouses, zones, bins, and users endpoints - Cleartext HTTP disabled for production (L5) --
usesCleartextTrafficgated to dev / preview profiles - Production docker-compose (L6) --
docker-compose.prod.ymlwith no source volume mounts
Migrations added: 014_password_changed_at.sql, 015_login_attempts.sql.
19 new tests (307 total).
v1.0.0 -- Production Release
2026-04-14. Full notes.
The first open-source warehouse management system built for e-commerce.
- Full warehouse lifecycle: Receive, Put-Away, Pick Walk, Pack, Ship, Cycle Count, Transfer
- React Native mobile app with Chainway C6000 broadcast-intent scanner support
- React admin panel with dark theme, warehouse context picker, audit log
- Inventory adjustments and inter-warehouse transfers
- CSV / JSON bulk import with templates
- Docker Compose one-command setup with demo data
- 288 automated tests passing
Security baseline: JWT with live database validation per request, warehouse authorization middleware on every endpoint, parameterized SQL throughout, login lockout, bcrypt hashing, CORS restriction, random admin password on first run, and a full pre-release audit.
MIT licensed. Free forever.