Skip to content

docs(spec): add SPEC-072 CRM detail pages UX enhancements - #1

Open
zielivia wants to merge 1 commit into
developfrom
feat/crm-detail-pages-ux-enhancements
Open

zielivia wants to merge 1 commit into
developfrom
feat/crm-detail-pages-ux-enhancements

Conversation

@zielivia

@zielivia zielivia commented Apr 6, 2026

Copy link
Copy Markdown
Owner

Summary

Adds SPEC-072 — UX enhancements for CRM detail pages (company, person, sales document), extending the existing SPEC-046 (customer detail v2) and SPEC-047 (sales document detail v2) with interaction improvements identified during the UX/UI meeting on 2 April 2026.

The spec addresses five critical UX gaps: information overload from non-collapsible form groups, high-friction activity creation (requires navigating to Zone 2 tab), single-owner limitation on entities, lack of visual deal progression, and unfiltered activity history.

Changes

  • Added .ai/specs/SPEC-072-2026-04-06-crm-detail-pages-ux-enhancements.md — full specification covering:
    • Collapsible CrudForm groups with persistent user preferences
    • Collapsible Zone 1 panel (entire left section collapses to strip, inspired by Tilio)
    • Inline activity composer (call/email/meeting/note) between Zone 1 and Zone 2
    • Multi-role assignment section (new customer_entity_roles entity)
    • Deal stage progress bar with click-to-advance and won/lost closure flow
    • Activity timeline filtering, planned/overdue section, and pinning
    • WCAG 2.1 AA compliance checklist
    • Integration tests (TC-UX-001 through TC-UX-005)
    • Scope classification (UX-only vs domain changes)
    • Decisions to confirm before implementation

Specification

Does a spec exist for this feature/module?

  • Yes
  • No (created a new spec)
  • N/A (minor change, no spec needed)

Spec file path: .ai/specs/SPEC-072-2026-04-06-crm-detail-pages-ux-enhancements.md

Related specs:

  • SPEC-046 (implemented) — Customer Detail Pages v2
  • SPEC-047 (draft) — Sales Document Detail Pages v2
  • SPEC-046b (implemented) — Customers Interactions Unification
  • SPEC-028 (implemented) — Multiple CRM Pipelines
  • SPEC-041 (implemented) — UMES

Testing

Spec-only PR — no code changes. Spec includes 5 integration test definitions (TC-UX-001 through TC-UX-005) to be implemented alongside the code.

Checklist

  • This pull request targets develop.
  • I have read and accept the Open Mercato Contributor License Agreement (see docs/cla.md).
  • I updated documentation, locales, or generators if the change requires it.
  • I added or adjusted tests that cover the change.
  • I added or updated integration tests in .ai/qa/tests/ (or documented why integration coverage is not required).
  • I created or updated the spec in .ai/specs/ with a changelog entry (if applicable).

Linked issues

Based on UX/UI meeting 2 April 2026 (Jarek, Oliwia, Maciej). Competitive analysis: Bitrix24, Pipe Drive, Tilio, Odoo.

zielivia added a commit that referenced this pull request Apr 29, 2026
CI shard 9 trace (run 25095248488 retry #1):
- safeFill toHaveValue 60s timeout, 59 polls = textarea empty under
  parallel-shard load.
- Initial run safeFill passed but waitForResponse 180s timeout, page
  snapshot showed the inline reply textarea empty at 180s — controlled
  state had been silently dropped between safeFill (typed body) and the
  submit button click.

keyboard.type races the React state commit when MessageComposer mounts
inside MessageDetailPageClient and runs its own effects in parallel —
characters land but are immediately overwritten before the caller can
proceed. locator.fill is atomic (`element.value = …` + dispatched
input event) and removes the per-keystroke race entirely.

Two fixes:
1. safeFill switched from click + focus + Ctrl+A + Delete + keyboard.type
   to locator.fill, with toHaveValue staying as the commit gate.
2. Pre-submit re-assertion in the inline reply scenario — fail loudly
   on a state drop instead of silently sending an empty-body POST that
   the response filter rejects.

Local: TC-MSG-009 passes in 6.3s.
zielivia added a commit that referenced this pull request Apr 30, 2026
CI shard 9 trace (run 25095248488 retry #1):
- safeFill toHaveValue 60s timeout, 59 polls = textarea empty under
  parallel-shard load.
- Initial run safeFill passed but waitForResponse 180s timeout, page
  snapshot showed the inline reply textarea empty at 180s — controlled
  state had been silently dropped between safeFill (typed body) and the
  submit button click.

keyboard.type races the React state commit when MessageComposer mounts
inside MessageDetailPageClient and runs its own effects in parallel —
characters land but are immediately overwritten before the caller can
proceed. locator.fill is atomic (`element.value = …` + dispatched
input event) and removes the per-keystroke race entirely.

Two fixes:
1. safeFill switched from click + focus + Ctrl+A + Delete + keyboard.type
   to locator.fill, with toHaveValue staying as the commit gate.
2. Pre-submit re-assertion in the inline reply scenario — fail loudly
   on a state drop instead of silently sending an empty-body POST that
   the response filter rejects.

Local: TC-MSG-009 passes in 6.3s.
zielivia added a commit that referenced this pull request Apr 30, 2026
CI shard 9 trace (run 25095248488 retry #1):
- safeFill toHaveValue 60s timeout, 59 polls = textarea empty under
  parallel-shard load.
- Initial run safeFill passed but waitForResponse 180s timeout, page
  snapshot showed the inline reply textarea empty at 180s — controlled
  state had been silently dropped between safeFill (typed body) and the
  submit button click.

keyboard.type races the React state commit when MessageComposer mounts
inside MessageDetailPageClient and runs its own effects in parallel —
characters land but are immediately overwritten before the caller can
proceed. locator.fill is atomic (`element.value = …` + dispatched
input event) and removes the per-keystroke race entirely.

Two fixes:
1. safeFill switched from click + focus + Ctrl+A + Delete + keyboard.type
   to locator.fill, with toHaveValue staying as the commit gate.
2. Pre-submit re-assertion in the inline reply scenario — fail loudly
   on a state drop instead of silently sending an empty-body POST that
   the response filter rejects.

Local: TC-MSG-009 passes in 6.3s.
zielivia added a commit that referenced this pull request May 2, 2026
CI shard 9 trace (run 25095248488 retry #1):
- safeFill toHaveValue 60s timeout, 59 polls = textarea empty under
  parallel-shard load.
- Initial run safeFill passed but waitForResponse 180s timeout, page
  snapshot showed the inline reply textarea empty at 180s — controlled
  state had been silently dropped between safeFill (typed body) and the
  submit button click.

keyboard.type races the React state commit when MessageComposer mounts
inside MessageDetailPageClient and runs its own effects in parallel —
characters land but are immediately overwritten before the caller can
proceed. locator.fill is atomic (`element.value = …` + dispatched
input event) and removes the per-keystroke race entirely.

Two fixes:
1. safeFill switched from click + focus + Ctrl+A + Delete + keyboard.type
   to locator.fill, with toHaveValue staying as the commit gate.
2. Pre-submit re-assertion in the inline reply scenario — fail loudly
   on a state drop instead of silently sending an empty-body POST that
   the response filter rejects.

Local: TC-MSG-009 passes in 6.3s.
zielivia added a commit that referenced this pull request May 2, 2026
Resolves the High and Medium findings from the @patrykk-com review on PR open-mercato#1730.

High:
- Migration-snapshot drift on sidebar_variants: the snapshot still listed the
  legacy `sidebar_variants_user_id_tenant_id_locale_name_unique` constraint
  even though Migration20260427124900 + 20260427143311 dropped it and replaced
  it with a partial unique index `WHERE deleted_at IS NULL` (which a `@Unique`
  decorator cannot represent). Drop the @unique decorator on `SidebarVariant`
  and remove the stale snapshot entry; partial index is owned by raw SQL in
  the migration. A follow-up `yarn db:generate` now diffs cleanly. (H #1)
- Move inline zod schemas (sidebarSettingsSchema, createVariantInputSchema,
  updateVariantInputSchema, variantRecordSchema) from variants route handlers
  into `data/validators.ts` and import them in both routes. Settings shape is
  shared with `sidebarPreferencesInputSchema` so the constraint definitions
  no longer drift. (H #2)

Medium:
- Replace `as any` / `: any` across the new sidebar code with `EntityManager`
  + typed `FilterQuery`. `parsed.data.settings as any` casts are gone now
  that service signatures accept `Partial<SidebarPreferencesSettings>` (which
  matches the inferred zod type). (M #3)
- Add explicit one-line rationale on every empty-catch block in AppShell
  (localStorage / cookie blocked in private mode — non-critical) and
  SidebarCustomizationEditor (`window.dispatchEvent` with no listener —
  AppShell refreshes on next navigation). (M #4)
- Replace raw `<button>` drag handle in SortableItemRow with `<IconButton
  variant="ghost" size="sm">` and use the existing forwardRef so
  `setActivatorNodeRef` and dnd-kit listeners still wire correctly. (M open-mercato#5)
- i18n hardcoded strings in SidebarPreview (`Search...`, `No groups to
  preview.`, `Drag to reorder`) — wrapped in `t(...)` and added 3 new keys
  to en/pl/de/es. (M open-mercato#6)
- Switch primitive: replace inline `shadow-[0_1px_2px_rgba(10,13,20,...)`
  arbitrary-value shadow with the new `--shadow-switch-thumb` CSS custom
  property in light + dark themes (and synced into the standalone template
  globals.css). Switch now uses `shadow-switch-thumb` Tailwind utility. (M open-mercato#7)
- Behavior regression for non-admin users: `requireFeatures:
  ['auth.sidebar.manage']` on the sidebar-customization page meta locked
  every non-admin user out of personal-scope customization, even though the
  variants/preferences APIs only gate role-application via that feature.
  Drop the page-level requireFeatures so any authenticated user can reach
  the page; the editor already conditionally hides "Apply to roles" via
  `canApplyToRoles` (server-checked against `auth.sidebar.manage`). (M open-mercato#8)

New tests:
- 6 unit tests in `sidebarPreferencesService.scope.test.ts` lock down the
  cross-tenant + cross-user scope guards on `loadSidebarVariant`,
  `updateSidebarVariant`, `deleteSidebarVariant`. Each test stubs
  `findOneWithDecryption` and asserts the exact `{ id, user, tenantId,
  deletedAt: null }` filter shape so a future refactor can't silently
  drop the user or tenant filter. (M open-mercato#9)

All 405 core test suites (3,329 tests) and 71 UI test suites (363 tests)
pass; build:packages clean across 18 packages.
pkarw pushed a commit that referenced this pull request Jun 17, 2026
… (report-high #1)

Fixes finding #1 from report-high.md: cross-user OpenCode session
continuation enables privilege escalation.

Root cause: handleOpenCodeMessage / handleOpenCodeMessageStreaming /
handleOpenCodeAnswer accepted a caller-supplied sessionId and resumed
the matching OpenCode session without verifying ownership; the chat
route only minted a fresh session-token api_key when !sessionId, so any
authenticated user with ai_assistant.view could resume another user's
OpenCode session and execute MCP tools under that identity.

Fix: add additive api_keys.opencode_session_id column with a partial
unique index; bind it on the first 'done' event in the chat route via
new bindOpencodeSessionToApiKey helper; assert (sessionUserId,
tenantId, organizationId) ownership via new
findApiKeyByOpencodeSessionId on every resume; surface opaque 'Session
not available' on all failure paths to prevent enumeration. Replace
unscoped getPendingQuestions() with owner-scoped
getOwnedPendingQuestions(em, auth); the deprecated overload now
throws to fail loudly.

Regression: api_keys.opencodeBinding.test.ts (5 cases),
opencode-handler-ownership.test.ts (10 cases),
chat-route-ownership.test.ts (8 cases),
TC-AI-CHAT-OWNERSHIP-001-opencode-session.spec.ts (4 cases).

Spec: .ai/specs/2026-05-24-fix-opencode-session-ownership.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
Sibling to dba38484d. Two commits because amending the original would
rewrite its SHA in turn — the tracker now matches HEAD exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
)

Fixes finding #3 from report-high.md: cross-tenant role create/update/delete
via body-supplied tenantId.

Root cause: roles route wired POST/PUT/DELETE through makeCrudRoute with
rawBodySchema=z.object({}).passthrough() and mapInput=parsed, forwarding
body.tenantId unchanged to commands whose lookups used a null-scoped filter
({tenantId: null, organizationId: null}). Any tenant admin holding
auth.roles.manage (granted via the seed admin role's auth.* wildcard) could
create roles in foreign tenants, reassign/rename roles in any tenant, or
delete roles in any tenant.

Fix: route-layer guard via enforceRoleTenantAccess (already existed for
create/update; extended with a 'delete' mode here) wired into every
mapInput; command-layer defense-in-depth via resolveActorScope +
buildScopedRoleFilter so update/delete lookups are tenant-scoped for
non-superadmins (404 on cross-tenant) and create+update reject body.tenantId
that differs from auth.tenantId for non-superadmins (403). Migrated the
touched em.findOne to findOneWithDecryption.

Regression:
- packages/core/src/modules/auth/lib/__tests__/roleTenantGuard.test.ts
  (7 new delete-mode cases)
- packages/core/src/modules/auth/commands/__tests__/roles.tenant-move.test.ts
  (updated tenant-move contract + new create/delete tenant-scoping cases)
- packages/core/src/modules/auth/api/__tests__/roles.route.test.ts
  (8 new wiring tests asserting cross-tenant rejection on each mapInput)

Local typecheck/jest blocked by pre-existing TS5103 (jest
ignoreDeprecations: '6.0' vs TS 5.9.3) — same blocker as #1 and #2;
build:packages, generate, i18n:check-sync all pass; CI must validate the
typecheck+test legs.

Deferred Low follow-ups: L1 replace rawBodySchema passthrough with strict
schemas whitelisting tenantId; L2 align delete cross-tenant response to 404
to match command-layer 404.

Sibling pattern flagged for separate tracker entry:
packages/core/src/modules/auth/api/users/route.ts uses the same passthrough
+ passthrough mapInput pattern and likely has an analogous cross-tenant
User.tenantId vulnerability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
…sers (tracker #4)

Fixes finding #4 from report-high.md: Hardcoded default password 'secret' for derived admin/employee users.

Root cause: setupInitialTenant fell back to the literal 'secret' for OM_INIT_ADMIN_PASSWORD / OM_INIT_EMPLOYEE_PASSWORD when the env vars were unset, and 'mercato auth setup' hardcoded includeDerivedUsers: true while only surfacing the primary user's password in stdout — leaving silently-seeded admin@<domain> and employee@<domain> accounts with the publicly known default in any production deploy that didn't pre-set the env vars.

Fix:
- setup-app.ts: remove the 'secret' literal; generate 16-char base64url passwords via randomBytes(12) when no env override is supplied; surface them on users[].generatedPassword.
- setup-app.ts: new DerivedUserPasswordRequiredError + production safeguard that throws when includeDerivedUsers is true, allowDemoDerivedPasswords is unset, and NODE_ENV=production lacks the env overrides. No partial DB state.
- setup-app.ts: additive SetupInitialTenantOptions.allowDemoDerivedPasswords?: boolean and SetupInitialTenantResult.users[].generatedPassword?: string | null (BC-safe).
- auth/cli.ts: new --include-demo-users flag (default off) controlling derived seeding; output loop now prints each generated password with a 'GENERATED — copy now' warning; catches DerivedUserPasswordRequiredError → exit 2.
- cli/init-secrets.ts: drop DEFAULT_DERIVED_PASSWORD = 'secret'; randomize unconditionally when overrides are unset; OM_INIT_GENERATE_RANDOM_PASSWORD becomes a deprecated no-op with a one-time warning.
- cli/mercato.ts: 'mercato init' passes --include-demo-users so the dev/demo bootstrap keeps seeding admin@/employee@ behind the same opt-in contract.

Regression:
- packages/core/src/modules/auth/__tests__/cli-setup-demo-users.test.ts — 4 cases: default no-seed; opt-in with autogenerated; opt-in with env overrides; production safeguard throws DerivedUserPasswordRequiredError.
- packages/cli/src/lib/__tests__/init-secrets.test.ts — rewritten to assert no 'secret' default, base64url randomization, and that OM_INIT_GENERATE_RANDOM_PASSWORD is a no-op.

Local test runner blocked by pre-existing TS5103 (jest ignoreDeprecations: '6.0' vs TS 5.9.3) — same blocker as #1/#2/#3; yarn typecheck blocked by pre-existing missing-re2js error in packages/queue. build:packages + generate + i18n:check-sync + i18n:check-usage pass. Code-review: 0 Medium+, 2 Low (deferred — dynamic import in new test; RELEASE_NOTES.md entry for the CLI default flip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
…open-mercato#8)

Fixes finding open-mercato#8 from report-high.md: MfaAdminService.resetUserMfa
allowed any tenant admin (granted security.* via per-tenant admin role)
to reset MFA for users in any other tenant because findUserById had no
tenant filter and no caller scope was forwarded.

Root cause: resetUserMfa(adminId, userId, reason) loaded the target
user via em.findOne(User, { id: userId, deletedAt: null }) with no
tenant scoping and the command never forwarded ctx.auth.tenantId.

Fix: Add MfaAdminAuthScope type. Add overloaded resetUserMfa with
scope parameter that loads the target via findOneWithDecryption,
filtered by scope.tenantId for non-superadmins, with an additional
cross-org check; cross-scope targets return null and surface as a
unified 404 MfaAdminServiceError ('User not found') to prevent
existence enumeration. Preserve the 3-arg overload as @deprecated;
it now fails closed (treated as non-superadmin with tenantId=null,
rejecting every load as 404). Command builds the scope from
ctx.auth.tenantId/orgId/isSuperAdmin and forwards it.

Regression:
- MfaAdminService.test.ts (5 new cases): same-tenant non-superadmin
  succeeds, cross-tenant non-superadmin rejected 404 with no method
  soft-deletes / no recovery-code invalidation / no security.mfa.reset
  event emitted, superadmin override succeeds, cross-organization
  non-superadmin rejected 404, deprecated no-scope call fails closed
  as 404.
- mfa-reset.route.test.ts (1 new case): MfaAdminServiceError statusCode
  404 from the command bus surfaces as HTTP 404 at the route boundary.

Local typecheck/test/build:app blocked by the same pre-existing
're2js' module-not-found + TS5103 jest --ignoreDeprecations issues
documented in tracker #1-open-mercato#7; build:packages + generate +
i18n:check-sync pass; targeted MfaAdminService + route tests pass
12/12 via root yarn jest. CI must validate the typecheck + test +
build:app legs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
…pen-mercato#13)

Fixes finding open-mercato#13 from report-high.md: cross-account MFA verification —
challenge not bound to authenticated user.

Root cause: MfaVerificationService.getValidChallenge looked up the
MfaChallenge by id alone, so prepareChallenge / verifyChallenge trusted
challenge.userId; the verify route then minted an mfa_verified JWT for
context.auth.sub. An attacker with A's password could complete A's MFA
gate using a different account B's challengeId and B's TOTP — full MFA
bypass for A. The sudo prepare route shared the same shape: it forwarded
session.userId as the MFA scope without verifying the SudoSession
belonged to the caller, so a known sudo sessionId let any caller drive
an OTP/SMS send under the victim's enrollment.

Fix: add MfaVerificationAuthScope and overloaded prepareChallenge /
verifyChallenge that require an authenticated { userId } scope.
getValidChallenge now filters by { id, userId } and fails closed with
404 (uniform with miss/expired to prevent enumeration). Deprecated
no-scope overloads throw 404 immediately without touching the DB (no
UUID-cast 500). Routes forward context.auth.sub; SudoChallengeService
forwards session.userId and now also accepts options.expectedUserId
mirroring its verify counterpart, with the sudo prepare route passing
context.auth.sub for ownership defense-in-depth.

Regression: MfaVerificationService.test.ts adds 6 cases (cross-user 404
on verify/prepare, deprecated-overload 404, same-user happy path).
challenge.route.test.ts asserts the scope argument is forwarded and
that route surfaces the 404. sudo challenge.route.test.ts asserts
expectedUserId is forwarded. Existing tests updated to thread the
scope.

Drive-by: fixed two `../../lib/...` → `../lib/...` relative-path bugs
in packages/ai-assistant/src/modules/ai_assistant/__tests__/chat-route-ownership.test.ts
introduced by tracker #1's commit 5ab0564 and noted in open-mercato#11/open-mercato#12 deferred
follow-ups, so yarn test runs ai-assistant. One pre-existing test
(`chat route — streaming branch wires auth + em through to the handler`)
remains failing because the streaming branch depends on a populated
ai-agents registry that jest cannot resolve — separate from open-mercato#13, deferred
to a follow-up tracker entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
… reaches the handler mock

Three streaming-branch tests in chat-route-ownership.test.ts asserted that
the chat route invokes `handleOpenCodeMessageStreaming` (and the post-`done`
binding step) but received zero mock calls because the background IIFE in
`api/chat/route.ts` stalls on `writeSSE` when nothing reads
`stream.readable`. A single `setImmediate` tick was not enough to clear
the un-drained TransformStream's backpressure, so the IIFE never reached
the handler call before the assertion ran.

Fix: replace the `setImmediate` wait with a `drainSseResponse()` helper
that reads `res.body` to completion. With the stream drained, every
`writeSSE` resolves, the IIFE runs through to `closeWriter()` in the
`finally` block, and the mocked handler is reached on the way.

Tests covered: "passes the OpenCodeAuthContext built from
auth.sub/tenantId/orgId", "calls bindOpencodeSessionToApiKey on a freshly
minted session", "does NOT call bindOpencodeSessionToApiKey when resuming
an existing sessionId".

Local: 83/83 ai-assistant test suites pass (1198/1198 tests). Previously
red on this PR's CI (introduced by tracker #1's commit 5ab0564 — masked
locally by the now-fixed TS5103 jest config, surfaced as the merge from
develop landed the jest config fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkarw pushed a commit that referenced this pull request Jun 17, 2026
… instead of 500

Standalone integration tests TC-AI-CHAT-OWNERSHIP-001 expect 403 'Session
not available' for every answerQuestion failure mode (unknown question
id, mismatched sessionId, foreign owner). CI runs the spec without a live
OpenCode backend, so `client.getPendingQuestions()` throws ECONNREFUSED
and the previous generic catch returned `{ error: <message> }` 500.

That violated finding #1's security intent: the answerQuestion short-
circuit MUST emit a uniform opaque response so an attacker cannot
distinguish "OpenCode is down" from "ownership check failed" from
"unknown question id" — every non-success outcome on this path is a
refusal-to-authorize from the caller's perspective.

Fix: drop the OpenCodeSessionOwnershipError special case and the generic
500 fallback; every catch path now returns 403 'Session not available'.
The actual error is still captured via `console.error('[AI Chat] Answer
error:', error)` so operators can debug from server logs. Removed the
now-dead `OpenCodeSessionOwnershipError` import.

Test impact: chat-route-ownership.test.ts (unit) — 8/8 pass. TC-AI-CHAT-
OWNERSHIP-001 (Playwright integration) — the two assertions on `Expected:
403` (lines 84, 111) now match the route's behaviour without needing a
live OpenCode container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zielivia pushed a commit that referenced this pull request Jul 29, 2026
docs(analysis): WMS Month 1 gap report (PL + EN)
zielivia pushed a commit that referenced this pull request Aug 5, 2026
* feat(telemetry): add @open-mercato/telemetry package with OTLP tracing, logs, metrics

Vendor-neutral OpenTelemetry telemetry as a workspace package (facade +
noop/console/otlp providers, env/init), app instrumentation, and
cross-boundary trace propagation.

- packages/telemetry: facade (logger, tracer, meter, reportError, redact),
  delegation-model TelemetryProvider with globalThis registry, backup-header
  propagator for GCP LB root-trace continuity.
- App wiring: instrumentation.node bootstrap, dispatcher reportError +
  http.server.request.duration metric, serverExternalPackages, .env.example.
- Queue propagation: metadata._trace carrier (local strategy) + bullmq-otel
  (async strategy), worker-process init.
- Tests: telemetry 47, queue 56, app 112.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): init telemetry before app graph loads so worker/scheduler jobs emit pg/undici spans

OpenTelemetry's pg/undici auto-instrumentation only records spans for a
module required AFTER the SDK has started. Standalone worker/scheduler
processes load MikroORM's Postgres driver (@mikro-orm/postgresql -> pg)
during CLI bootstrap, before runWorker() called initTelemetry() — so job
handlers' DB queries produced no spans and the trace showed only the
bullmq-otel add/process/complete envelope with an empty job body.

- packages/cli/src/bin.ts: call initTelemetry() before dynamically
  importing the mercato entry, for every bootstrap-requiring command
  (worker, scheduler, …). No-op when telemetry is disabled. runWorker's
  in-process init stays as an idempotent fallback.
- packages/cli: add @open-mercato/telemetry dependency.
- packages/telemetry: spawned-subprocess test locking that an OTLP-backed
  provider instruments pg so a query emits a pg.query span (jest's module
  system can't exercise require-in-the-middle faithfully).
- spec: S4 load-order requirement, R15, Testing section, changelog.

Verified end-to-end on a live OTLP backend: unfixed worker -> process span
with 0 pg children; fixed worker -> full findPendingVerification query tree
nested under process <queue>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(create-app): wire telemetry into scaffolded app template (parity with apps/mercato)

The telemetry feature (commit d75042e) wired @open-mercato/telemetry into
apps/mercato but left the create-app template untouched, so a freshly
scaffolded app had no web-tier telemetry: setting TELEMETRY_BACKEND produced
silence. Per packages/create-app/AGENTS.md, app-shell changes MUST be synced
to the template. This ports the six app-side wiring points:

- package.json.template: add @open-mercato/telemetry dependency and
  bullmq-otel optionalDependency (the @opentelemetry/* SDK arrives
  transitively as optionalDependencies of @open-mercato/telemetry, matching
  apps/mercato which also does not list them directly).
- src/instrumentation.ts: initialize telemetry on the Node runtime via a
  conditional import (edge runtime is skipped — OTEL NodeSDK is Node-only).
- src/instrumentation.node.ts (new): initTelemetry() + SIGTERM/SIGINT flush,
  degrading to no telemetry on init failure.
- src/app/api/[...slug]/route.ts: recordHttpDuration
  (http.server.request.duration histogram) on completion + reportError funnel
  on 5xx. Kept byte-identical to apps/mercato so the template-sync test passes.
- next.config.ts: externalize all @opentelemetry/* packages
  (serverExternalPackages) so the pg/undici auto-instrumentations patch the
  real drivers instead of a bundled copy.
- .env.example: document the TELEMETRY_* / OTEL_EXPORTER_OTLP_* variables.

The worker/scheduler telemetry fix already ships via @open-mercato/cli, which
now depends on @open-mercato/telemetry — scaffolded apps get it through the
{{PACKAGE_VERSION}} pin once the version is published.

Validation: build:packages 22/22, telemetry 48/48, create-app 61/61
(including the template↔monorepo byte-identity dispatcher sync test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): align package version to 0.6.5 (monorepo lockstep)

The telemetry package shipped at 0.6.3 while every other public package is
at 0.6.5. scripts/check-version-alignment.sh enforces lockstep against
packages/shared, so 0.6.3 is a release blocker: create-app scaffolds pin all
@open-mercato deps to {{PACKAGE_VERSION}} (the create-app version, 0.6.5), so
`@open-mercato/telemetry@0.6.5` had no candidate and a fresh app failed
`yarn install` with "No candidates found". Surfaced by yarn test:create-app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(create-app): add telemetry files to the template sync checklist

The Template Sync Checklist listed layout/dev-script parity but not the
telemetry wiring, so future changes to instrumentation.ts / the dispatcher
route / next.config serverExternalPackages / .env.example / package.json.template
could drift out of sync (the exact gap this branch fixed). Adds entries 9–14,
including the byte-identical dispatcher rule and the version-lockstep
requirement for @open-mercato/telemetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): gate backend log export by level; memoize queue telemetry resolution

Two review fixes:
- logger: gate provider.emitLog by the configured TELEMETRY_LOG_LEVEL, so
  below-level records no longer ship to the OTLP backend (stdout was already
  gated). Controls remote log volume/cost, not just stdout.
- async queue: memoize the in-flight bullmq-otel resolution as a promise
  instead of a boolean set before the await, so concurrent first-time callers
  share one result and a queue/worker pair can't be built with inconsistent
  telemetry wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(telemetry): cover backend log-level gating and concurrent queue telemetry wiring

Regression guards for the two review fixes:
- logger-level: below-level records must not reach provider.emitLog (fails
  against the ungated export).
- async.telemetry: concurrent enqueue+process must wire the SAME bullmq-otel
  instance into both Queue and Worker (fails against the boolean-before-await
  memoization, which left the worker untraced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(telemetry): sync spec with create-app parity + review fixes

The spec was accurate through the 2026-07-01 worker-DB-spans entry but
lagged the last five commits on the branch. Brings it up to the current
tip (f95a4dd):

- create-app template parity (c98aee6/34a64112/7682c955): the six
  app-side wiring points ported to the scaffold template, version
  lockstep fix (0.6.3 -> 0.6.5), Template Sync Checklist entries, and
  the live SigNoz scaffold validation -- documented in touched-areas,
  Testing, and a changelog entry (the spec had zero create-app mention).
- review fixes (5db884a): backend log export now gated by
  TELEMETRY_LOG_LEVEL; async-queue bullmq-otel resolution memoized as an
  in-flight promise -- captured in the env table, touched-areas, and a
  changelog entry.
- new regression tests (f95a4dd): logger-level + async.telemetry;
  updated counts (telemetry 48 -> 50, queue 58).

Spec-only change; no product code touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(telemetry): shrink app adoption surface via @open-mercato/telemetry/nextjs

Existing apps had to hand-copy the web-tier wiring (a 15-entry
serverExternalPackages list, an instrumentation.node.ts, an inline HTTP
metric fn), and a *partial* externals copy silently disabled exporting —
the #1 footgun. Move the boilerplate into the package so apps and the
scaffold template consume helpers instead.

New packages/telemetry/src/nextjs.ts subpath (resolved via the ./* export;
import-safe from next.config.ts — never statically imports @opentelemetry/*
or the pino logger, dynamic-imports ./init inside the register fn):

- telemetryServerExternalPackages — the full @opentelemetry/* list as a
  single source of truth to spread into serverExternalPackages; a partial
  copy is now impossible.
- registerTelemetryForNextjs() — one-line instrumentation.ts bootstrap
  owning init + graceful degrade + SIGTERM/SIGINT flush + edge-runtime
  skip. Deletes instrumentation.node.ts from apps/mercato and the template.
- recordHttpDuration() — the semconv http.server.request.duration
  histogram, moved out of the inline dispatcher (route.ts stays
  byte-identical app<->template, now importing the helper).

apps/mercato (next.config.ts / instrumentation.ts / route.ts) and their
template mirrors consume the helpers; create-app Template Sync Checklist
updated (dropped the now-nonexistent instrumentation.node.ts row).

Docs (README): new "Adopting in an existing app" section (manual
Scenario-B steps) + /nextjs API table; corrected a stale
"worker spans run through the no-op provider" note.

Tests: nextjs.test.ts (externals completeness, recordHttpDuration semconv
shape, register no-op + edge-skip). telemetry 50->54.

Validation: build:packages 22/22, typecheck 22/22 (app + template +
telemetry), lint clean, create-app 61/61 (byte-identity dispatcher sync
still green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): add `mercato telemetry init` to wire telemetry into existing apps

The web-tier telemetry wiring lives in app-owned source files, so a
dependency bump can't deliver it to an app scaffolded before telemetry
existed. Add a bootstrap-free command that patches it in — deterministic,
idempotent, and --dry-run-able. Chosen over an agent skill because after
the /nextjs surface reduction the work is mostly mechanical, so a
unit-testable command with no AI-tool dependency fits; modeled on
`mercato agentic:init` and `deploy railway --write-env`.

packages/cli/src/lib/telemetry-init.ts (dispatched from mercato.ts, added
to bin.ts BOOTSTRAP_FREE_COMMANDS) validates it's an OM app then applies
six idempotent steps:
- package.json: add @open-mercato/telemetry pinned to the app's existing
  @open-mercato version + optional bullmq-otel.
- .env.example (+ .env if present): append the commented TELEMETRY_*/OTEL_*
  block when TELEMETRY_BACKEND is absent (detect-before-append).
- src/instrumentation.ts: create if missing, else insert the guarded
  registerTelemetryForNextjs() block into register().
- next.config.ts: ts-morph confirms serverExternalPackages is a real array
  literal, then a formatting-preserving text splice adds the import +
  ...telemetryServerExternalPackages.
- the API dispatcher: auto-patch via anchored insertion (imports +
  success-path recordHttpDuration + catch reportError/500 metric) ONLY when
  the known scaffold shape is recognized; otherwise print the snippet and
  flag a manual step rather than editing an unrecognized handler.

run() no longer auto-creates .env for the telemetry command (excluded from
ensureEnvLoaded like deploy). The env block + instrumentation content are
duplicated from the template into telemetry-init.ts — noted in the
create-app Template Sync Checklist.

Tests: telemetry-init.test.ts (app-guard, full pre-telemetry wire,
idempotent re-run with no double-insert, customized-dispatcher manual
fallback, dry-run writes nothing). Full CLI suite 992/992, cli typecheck +
build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): harden telemetry-init coverage; fix single-line serverExternalPackages

Expand telemetry-init tests to prove the command actually works, not just
that strings were inserted:

- syntactic validity: every patched file (next.config.ts, instrumentation.ts,
  dispatcher) is parsed with the TypeScript compiler and asserted to have zero
  syntax errors.
- live-template round-trip: strip telemetry from the REAL scaffold dispatcher,
  re-apply, and assert it reproduces the shipped wiring — ties the test to the
  template so it fails if either drifts.
- no-op on the real already-wired template files (detection matches what ships).
- variation coverage: recognizable-but-modified dispatcher (custom code
  preserved), unrecognizable dispatcher (untouched + manual snippet),
  next.config single-line AND multi-line arrays, serverExternalPackages absent
  (manual), and a pre-existing custom instrumentation.ts (body preserved).

The expanded suite caught a real bug: the next.config spread insertion only
matched the multi-line array form, so a single-line
`serverExternalPackages: ['esbuild']` was half-patched (import added, spread
missing). Fixed by splicing at the ts-morph array node's byte offsets instead
of an outer regex — handles both forms and stays formatting-clean.

telemetry-init 12/12, full cli suite 999/999, typecheck + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): redact secrets in reported context, not just emails in text

Addresses PR open-mercato#25 review: the redaction backstop only scrubbed emails from
error text, so a secret in the reported context (e.g. an Authorization
header attribute) or an inline auth token in a message would still ship to
the backend.

- redactPii now also masks Bearer/Basic tokens and Authorization/Cookie
  header dumps embedded in free text (message + stack).
- new redactAttributes(): masks values under a secret-looking KEY
  (authorization, set-cookie, client_secret, x-api-key, access_token, …;
  specific enough that token_count survives) and runs redactPii over other
  string values.
- applied at the single writeRecord log chokepoint, so it covers reportError
  context AND every logger.* attribute bag — mirroring how serializeError
  already runs error text through redactPii.

Declined the paired "use Zod for the email regex" note: Zod validates whole
values, but redaction needs substring scan/replace over free text; the
always-loaded facade also avoids heavy deps.

Tests: redact.test.ts (auth-token/header text, secret-keyed attributes,
token_count preserved) + reportError-context e2e in telemetry.test.ts.
telemetry 54->61; typecheck (telemetry/queue/app) + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry,cli,queue): honor .env for CLI telemetry init; flush on worker shutdown

Two confirmed review findings (Kamil) + one suggestion (Patryk) on PR open-mercato#25.

1. CLI-launched processes ignored TELEMETRY_* set only in .env. bin.ts's
   static telemetry import evaluated the logger, whose module-scope
   readTelemetryEnv() stamped the env cache before any .env load;
   initTelemetry() then consumed the stale snapshot (backend -> noop) and
   run()'s later ensureEnvLoaded() came too late. So `mercato queue worker`
   with the backend only in .env — the exact path .env.example and
   `telemetry init` point users at — silently emitted nothing. Fixed at all
   three layers:
   - logger resolves env lazily per write (pino keyed on the memoized env
     object, rebuilt after a cache reset) — no import-time stamp;
   - initTelemetry() resets the env cache so init reads the fully-loaded
     environment;
   - bin.ts pre-loads the app's .env via a new bootstrap-free
     lib/load-env.ts (resolver + dotenv only, no pg — preserves the
     instrumentation load-order guarantee) before initTelemetry().
   Verified e2e: a fixture app with TELEMETRY_BACKEND=console only in .env
   now logs `telemetry initialized {backend: console}` from the worker entry.

2. Worker graceful shutdown never flushed telemetry: the SIGTERM/SIGINT
   handler closed queues then process.exit(), and a worker never returns from
   run(), so bin.ts's post-run shutdownTelemetry() was unreachable — the
   BatchSpanProcessor's ~5s tail was dropped on every restart/redeploy. The
   handler now awaits shutdownTelemetry() (failure logged, never fails the
   shutdown) after queue close, before exit.

3. Add ApiKey to the auth-scheme text-redaction pattern (review suggestion).

Also guard the optional provider.activeTraceContext?.() in writeRecord — a
custom provider omitting it crashed every log write.

Tests: env-load-order.test.ts (backend/log-level set after facade import must
win), worker-shutdown-telemetry.test.ts (SIGTERM flushes before exit),
load-env.test.ts, ApiKey redact case. telemetry 63, queue 59, cli 1002 green;
typecheck + builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(telemetry): make spec and test docstrings self-contained

The spec changelog and three new test docstrings referenced the internal
fork review (PR number, reviewer attributions) — meaningless and dead links
once this lands upstream. Reworded as self-contained defect descriptions;
technical content unchanged. Comment-only change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(app): assert dispatcher telemetry at the recordHttpDuration seam

The api-telemetry test still mocked histogram from @open-mercato/telemetry,
but the dispatcher now records the http.server.request.duration metric via
recordHttpDuration from @open-mercato/telemetry/nextjs, whose internal
histogram import is relative and bypasses the package mock — so no calls
were captured. Mock the /nextjs entry point instead and assert the
method/route/status wiring; the semconv histogram shape is covered by the
telemetry package's own nextjs tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): inherit the shared jest base config

The memory fan-out guard (scripts/__tests__/jest-memory-fanout.test.mjs)
requires every package jest config to inherit the bounded maxWorkers and
workerIdleMemoryLimit caps from jest.config.base.cjs; the telemetry config
predated that guard on this branch and defined a standalone config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(telemetry,queue,cli): close review gaps in the telemetry test suite

Fixes from an adversarial review of every test file on this branch:

- queue/async.telemetry: restore TELEMETRY_BACKEND after the suite —
  the module-scope 'otlp' leaked into the shared process.env and broke
  sibling queue suites whenever jest's file ordering shifted.
- telemetry/nextjs: derive the expected serverExternalPackages set from
  the provider's real @opentelemetry/* imports instead of a second
  hand-maintained subset, and assert set-equality both ways.
- telemetry/pg-instrumentation: probe the provider's DEFAULT
  instrumentation list (previously the child injected its own
  PgInstrumentation, leaving the production wiring unguarded) and assert
  bound query parameters never appear in span attributes.
- telemetry/telemetry: the 'awaits async work' test was synchronous;
  it now drives an async callback through withSpan and asserts the span
  ends only after the awaited work completes.
- cli/load-env: add the missing guard for the documented regression —
  bin.ts must await loadAppEnv() before initTelemetry().
- telemetry/env-load-order: reset the globalThis-keyed active provider
  between tests; logger-level: correct a stale import-time-binding
  docstring; queue/worker-shutdown: drop an unasserted claim from the
  test name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): re-raise SIGTERM/SIGINT after the Next.js flush

Registering a signal listener suppresses Node's default termination, so
registerTelemetryForNextjs()'s flush-only handler left the web process
alive after SIGTERM until the orchestrator force-killed it. The once-
installed handler now awaits the best-effort flush and re-raises the
signal, restoring default terminate semantics.

Regression-locked by nextjs-shutdown.test.ts: a spawned child registers
the helper, receives a real SIGTERM, and must exit with that signal —
verified to fail against the pre-fix build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy the telemetry package manifest in the dependency layer

The Dockerfile now installs from per-workspace package.json copies
(dockerfile-runtime-copy guard scans packages/*), so the new
packages/telemetry workspace must be copied before yarn install in both
stages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(queue,telemetry): satisfy the structured-logging console gate

Upstream's logger:check-console:ci gate (structured-logging facade,
merged from develop) flags raw console.* calls. Convert the two queue
call sites to the package logger already in scope, and allowlist
telemetry's nextjs.ts init-failure warn: that module must stay
next.config-eval-safe, the package has no @open-mercato/shared
dependency, and the warn fires exactly when telemetry init failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): unify logging and harden opt-in loading

* fix(telemetry): bound email redaction matching

* fix(telemetry): upgrade OpenTelemetry past Jaeger advisory

* fix(docker): include telemetry workspace in runtime focus

* test(sales): seed required lines in ledger route cases

* test(record-locks): seed valid sales orders

* test(payment-gateways): create valid reconciliation orders

* test(sales): repair order-line integration fixtures

---------

Co-authored-by: Jakub Birecki <kubabir@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zielivia pushed a commit that referenced this pull request Aug 26, 2026
…open-mercato#4391)

* feat(channel-discord): scaffold provider package (adapter, REST client, credentials, health, DI)

Adds the @open-mercato/channel-discord package implementing the existing
communication_channels ChannelAdapter contract for a Discord bot:
- DiscordChannelAdapter (send/edit/delete/reactions via REST, resolveContact,
  fetchHistory backfill, validateCredentials, fail-closed verifyWebhook)
- Thin fetch-based Discord REST client (no discord.js), swappable in tests
- Ed25519 interactions verification via node:crypto (no tweetnacl)
- Credential + channel-state zod schemas, capabilities, health check
- integration.ts / di.ts / setup.ts / acl.ts registration
- Registered in app + create-app template modules.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channel-discord): gateway worker + signed interactions route

- Discord Gateway WebSocket client (native WebSocket, no ws dep) with
  identify/resume/heartbeat state machine + pure testable helpers
- Long-running gateway worker bridging MESSAGE_CREATE / reactions into the
  hub's existing inbound + reactions queues (no hub change); bot-self-message
  feedback-loop guard; requires_reauth on fatal close codes; honours
  OM_CHANNEL_DISCORD_GATEWAY_DISABLED
- Provider-owned signed Interactions route (Ed25519 fail-closed, synchronous
  PING->PONG) resolving the spec's one hub touch-point without touching the hub
- Pure gateway-bridge + interactions-handler helpers for unit testing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channel-discord): optional AI auto-reply subscriber

- Listens to communication_channels.message.received (provider discord)
- Soft-resolves ai_assistant via DI (mcpToolRegistry); no-ops when the peer
  is absent so the channel still works as a plain inbox (module-decoupling)
- Easy-vs-complex classifier (SPEC-056 tiering): easy auto-replies text-only,
  complex is propose-only (no auto-send); default OFF per channel
- Drafts via runAiAgentObject (dynamic import, optional peer) and sends through
  the generic hub outbound path (compose -> outbound-bridge -> deliver_outbound)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channel-discord): connect widget + i18n (en/pl/de/es)

- Profile connect injection widget (bot token, application id, public key,
  guild id, default channel id) via the shared credential connect route
- injection-table wiring into profile:communication-channels:connect
- i18n locale files (en/pl/de/es) for all user-facing connect strings; no
  hardcoded user-facing text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(channel-discord): unit tests (Ed25519, mapping, sendMessage, gateway, subscriber)

57 unit tests covering:
- Ed25519 interaction verification: valid / tampered body / tampered timestamp
  / missing signature / wrong key -> all fail-closed
- Interactions handler: PING->PONG, tampered->401, tenant isolation (public-key
  pinning), no-candidate->401
- Discord message -> hub NormalizedInboundMessage mapping + bot-self filter
- sendMessage builds the correct REST createMessage request (channel resolution,
  2000-char clamp, defaultChannelId fallback) + validateCredentials
- convertOutbound markdown passthrough / html down-convert / clamp / mentions
- gateway state-machine helpers (intents, identify/resume/heartbeat, backoff,
  fatal close codes) + gateway->hub bridge job building
- ai-reply classification + subscriber no-op when ai_assistant absent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(channel-discord): link workspace package (yarn.lock)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channel-discord): gateway start trigger (CLI) + connection reconciliation

Review blockers #1 and #2:
- Add channel_discord 'start-gateway' CLI: enqueues the bootstrap job, runs the
  gateway worker, and re-enqueues a refresh job on an interval so newly connected
  channels are picked up and deactivated/soft-deleted ones are reconciled away.
  Inbound was otherwise silently dead (realtimePush disables hub polling and
  nothing enqueued the channel_discord_gateway job). Note the requirement in
  integration.ts description.
- Full connection reconciliation each run: close + drop sockets whose channel
  left the active set (isActive=false / soft-delete) instead of leaking the
  socket + heartbeat timer forever. Track tenantId per connection so a scoped
  refresh never tears down another tenant's sockets. Exported pure
  reconcileGatewayConnections + unit tests (stale close, active keep, tenant
  isolation).
- Align integration.ts version with package.json (0.6.6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel-discord): heartbeat-ACK zombie detection on the gateway

Review non-blocker #4: handle gateway opcode 11 (Heartbeat ACK). A missing ACK
before the next heartbeat means a zombied connection (TCP up, gateway dead) —
force a close(4000) so the close handler reconnects (with resume) instead of
staying silently deaf. Add a pure createHeartbeatMonitor state machine
(onBeat/onAck/reset) wired into the session + unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel-discord): drop ALL bot-authored inbound messages

Review non-blocker #5: the feedback-loop guard now drops any message flagged
author.bot (other bots / webhooks in the channel), not only our own bot's user
id — prevents cross-bot loops and AI auto-reply noise. Own-bot id check retained
as a belt-and-suspenders fallback. Mapping test updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(channel-discord): full AI auto-reply subscriber gating tests

Review blocker #3: exercise the whole subscriber with mocks —
- easy -> drafts (runAiAgentObject) + sends via messages.messages.compose
- complex/keyword -> propose-only, NEVER calls the agent or sends
- ai_assistant absent -> clean no-op, message never loaded, nothing sent
- per-channel auto-reply OFF (default) -> no-op
- channel loaded scoped by tenant + organization
Nails down the 'never auto-sends privileged/complex' guarantee at the subscriber.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel-discord): reject replayed interaction signatures (timestamp freshness)

Discord signs timestamp+body, but a captured request stays cryptographically
valid forever. Enforce a +/-300s freshness window (DISCORD_SIGNATURE_MAX_SKEW_
SECONDS) before the per-candidate Ed25519 fan-out, so a replayed capture is
rejected at constant cost with 401 stale_timestamp. Fail-closed on missing or
non-numeric timestamps; clock injectable for tests.

Addresses the review's 'interactions replay / timestamp freshness' follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel-discord): harden easy/complex classifier against prompt injection

The auto-reply gate matched keywords against raw text only, so cheap
obfuscation (fullwidth chars, zero-width splits, markdown emphasis/spoilers,
combining-mark zalgo) or a steering prompt could slip past it into the
auto-send path. Now:

- signals match a normalized copy too (NFKC, invisible chars stripped,
  markdown emphasis stripped, NFKD combining marks stripped)
- injection attempts (ignore instructions, system prompt, act as,
  role markers, ...) force complex -> propose-only
- messages carrying links are propose-only (unvetted instruction channel)
- any zero-width/bidi character is treated as obfuscation -> propose-only

Defense-in-depth over the existing containment (propose-only, features: [],
allowed_mentions parse: []), per the review's hardening follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel-discord): sort i18n keys so the CI i18n-sync gate passes

`yarn i18n:check-sync` requires flattened, sorted locale files. The four
channel_discord locale files were unsorted, which turned the `test` job red
once the base merge un-skipped it (it had been skipped behind the `audit`
gate). Reordering only — key count is unchanged at 15 per locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docker): copy the channel-discord manifest before the source copy

The Dockerfile pre-copies every workspace package.json so yarn can install
from manifests before the full source copy. packages/channel-discord was
added by this PR without its three entries, which fails the
dockerfile-runtime-copy guard and would break the layer cache for the new
package.

Adds the manifest copy to both dependency-install stages and the runtime
stage.

* fix(channel-discord): stop gateway churn and scope resume-state writes

Two defects in the gateway worker, both reported in review.

Churn: the worker restarted every active channel on every job run, and the
CLI's 60s refresh made that a re-IDENTIFY loop — healthy sockets were closed and
replaced once a minute, burning Discord's session-start budget and dropping the
dispatches in flight. The refresh is now a reconciler: a channel whose session
is still running is left untouched and only a stopped handle is replaced.
`DiscordGatewayHandle` gains `isActive()` so the worker can tell a session that
is merely reconnecting with backoff (self-healing) from one that is really dead.

Opcode 9 (INVALID_SESSION) previously cleared the resume state and returned,
leaving the socket open but permanently deaf: no dispatches, no reconnect, no
error. It now honours Discord's `resumable` flag — clearing the session only
when it is not resumable — and closes the socket so the existing close handler
runs the shared backoff plus the correct IDENTIFY/RESUME handshake.

Scoping: resume state was persisted with an unscoped `findOne({ id })` and a
flush issued straight from a socket callback. Writes now go through a
provider-local store that filters by `tenant_id` / `organization_id`, merges
only the gateway-owned keys (so a concurrent operator edit of
`aiAutoReplyEnabled` is not clobbered), refuses a patch that would rewind the
sequence of the session already stored, and runs on its own EntityManager fork.
The worker serializes those writes per channel so two callbacks cannot
interleave their read-modify-write.

* fix(channel-discord): declare only the capabilities the adapter implements

The capability profile advertised file sharing, typing indicators, presence,
rich blocks, interactive components, inline images and stickers, none of which
this adapter implements: `convertOutbound` drops `content.attachments`,
`discord-rest` has no multipart upload, the bot identifies without the
`GUILD_PRESENCES` intent, outbound is plain markdown `content`, and the
Interactions endpoint answers with a deferred ack and never follows up.

The hub routes work to the adapter based on these flags, so each one is now set
to what actually works today; `threading`, `richText`, `reactions`,
`editMessage`, `deleteMessage`, `conversationHistory` and `realtimePush` stay
enabled because each is backed by a real adapter method. The doc comment names
the code behind every enabled flag and what has to land before a disabled one
may flip.

`convertOutbound` now logs and reports `droppedAttachmentCount` instead of
discarding attachments silently, so a mis-routed attachment is observable.

* fix(mercato): declare the channel-discord dependency the app registers

`apps/mercato/src/modules.ts` registers `@open-mercato/channel-discord` but the
app's `package.json` never declared it, so resolution worked only through
workspace hoisting and would break under an isolated install.

* feat(channel-discord): add the provider-owned env preconfiguration the spec requires

SPEC 2026-06-19 promises deployment-managed bootstrap via `OM_CHANNEL_DISCORD_*`
applied from `setup.ts` and rerunnable through a provider CLI command; `setup.ts`
only registered the adapter.

`lib/preset.ts` reads the vars and persists them through the standard
`integrationCredentialsService` (never a core special case). A half-filled
preset throws instead of persisting a bot token that cannot authenticate, and
existing credentials — the normal `/backend/integrations` connect flow — are
kept unless `OM_CHANNEL_DISCORD_FORCE_PRECONFIGURE` says otherwise, so both
entry points are idempotent. `setup.ts` applies it on tenant creation and
`yarn mercato channel_discord configure-from-env --tenant <id> --org <id>`
re-applies it to tenants that predate the vars or after a token rotation.

Both `.env.example` files document the vars (create-app template sync), and the
`start-gateway` doc comment now states that the periodic refresh is a
reconciler, not a re-connector.

The standalone scaffold keeps the package installed but leaves `channel_discord`
commented out in `template/src/modules.ts`: enabling it makes a 49th entry in
the Codex root's enabled-module fact index, which pushes the generated AGENTS.md
past its 12 KiB `project_doc_max_bytes` budget. That budget review is a
maintainer call, so the line ships ready to uncomment rather than red.

* fix(channel-discord): serve the interactions endpoint at its documented URL

The route file sat at `api/post/channel_discord/interactions/route.ts`, but the
API generator already prefixes the module id — so the endpoint was registered at
`/api/channel_discord/channel_discord/interactions` while the route's own doc
comment, `integration.ts` and the operator instructions all say
`/api/channel_discord/interactions`. An operator following the documentation
would point Discord's Interactions Endpoint URL at a 404 and the mandatory PING
handshake would never complete, so slash commands and buttons could not be
enabled at all.

Moved the file under `api/post/interactions/` and pinned `metadata.path`
explicitly, the way the hub's `webhooks/gmail` route does: the operator-facing
URL is part of this route's contract, not a by-product of where the file sits.

Caught by TC-CHANNEL-DISCORD-005/008, which no unit test could have caught —
the mismatch only exists in the generated route manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(channel-discord): ship the spec's integration coverage (TC-001..008)

Review finding: the spec requires TC-CHANNEL-DISCORD-001..010 to ship with the
implementation and its compliance report claimed they did, but the package held
only mocked jest tests — no module-local `__integration__` suite.

Eight executable Playwright specs now drive the real app:

- 001 the `discord` adapter is registered (an unknown provider key 404s, discord
  422s) and `validateCredentials` fails closed offline with per-field errors; a
  rejected connect leaves no half-created channel
- 002 test-send refuses unauthenticated callers, malformed ids and channels the
  caller does not own, before any adapter resolves
- 003 a `providerKey: 'discord'` inbound message persists as a delivered inbound
  link inside the channel's health window
- 004 reaction add/remove round-trip on a Discord message through the hub's
  thread mapping
- 005 the signed interactions route is fail-closed on every path: unsigned,
  fresh-timestamp-without-signature, unknown key, tampered body, replayed
  timestamp (the freshness guard answers before the candidate fan-out), non-JSON
- 006 an inbound Discord message from a known contact creates EXACTLY one CRM
  interaction — the provider adds no contact-resolution logic of its own
- 007 the health surface is tenant-scoped and reports a fixed numeric snapshot
  that counts Discord traffic
- 008 the shared interactions URL is a black box for an unverified caller:
  identical rejections across applications, no channel/tenant/organization
  identifier in the body

Ed25519 request signing is generated per test (`helpers/discordSignature.ts`),
so no Discord application, bot token or network call is involved. Specs that
need seeded traffic use the hub's env-gated fixture and skip with a stated
reason when `OM_ENABLE_TEST_CHANNEL_SEEDING` is off, matching the hub's own
`TC-CHANNEL-API-*` convention; 001, 002, 005, 008 and 007's guards need no
fixture.

Each spec states the ceiling of what is assertable without a live Discord
application and names the unit test that owns the other half. The spec's
compliance report and a new section record that honestly, and TC-009/TC-010 move
with the AI auto-reply feature they assert on (#4778).

Verified: 20/20 pass against an ephemeral app + Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(channel-discord): de-scope AI auto-reply from the first release

The 2026-07-30 re-review established that the subscriber cannot invoke any
agent this repository ships (object-mode call with empty features against
chat-mode, feature-gated agents) and that nothing writes the channel-state
keys that arm it. Rather than design AI invocation underneath a provider PR,
the first release stops promising the capability: the module and integration
descriptions no longer advertise it, the `ai` tag is gone, and the subscriber,
its helpers and the channel-state keys document that they ship dormant and why
arming them by hand would still be denied. The complex tier's log line no
longer claims an approval surface that does not exist.

Production agent invocation, the configuration path, the proposal surface and
TC-CHANNEL-DISCORD-009/010 are tracked in #4778. No behavior change.

* fix(channel-discord): align react and ts-jest devDependencies with the monorepo

packages/channel-discord pinned react/react-dom 19.2.7 and ts-jest ^29.4.11
while every other workspace package (and the committed lockfile) is on
react/react-dom 19.2.8 and ts-jest ^29.4.12. That divergence made
`yarn install --immutable` want to add react@19.2.7 / react-dom@19.2.7
resolutions and split the ts-jest descriptor, so YN0028 failed the install
step and took prepare, lint and audit down with it.

Match the sibling channel packages (channel-gmail, channel-imap) and
regenerate the lockfile; the only lockfile change is the workspace
descriptor block, no new resolutions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channel-discord): add the ko locale the base now requires

develop added Korean as a fifth locale; yarn i18n:check-sync fails the test
job for any module missing ko.json. Translates the 15 existing keys rather
than stubbing them with English, matching the sibling modules' ko files.

* fix(channel-discord): quarantine fatally-rejected channels, fail fast on a local queue

Addresses the autofixable half of @Kapsik89's QA report on #4391. The two
functional blockers (#4975 inbound rejected, #4976 no send path) are NOT touched
here — both need a maintainer decision on whether the hub keeps requiring an
email address to identify an external correspondent.

#4979 — a channel Discord fatally rejected (4004 bad token, 4014 disallowed
intents) is now persisted as `requires_reauth` with the close code as
`last_error`, and the reconciler skips it. Previously `onRequiresReauth` only
emitted an event nothing subscribes to for state changes, so every tick saw "no
live session" and IDENTIFYed again: 16 fatal closes over 10 ticks at
`--refresh 5`, ~1440 session starts/day at the default refresh against Discord's
~1000/day per-bot budget, with the row still reading `connected`.

#4978 — `start-gateway` refuses to start unless `QUEUE_STRATEGY=async`. It runs a
worker for the gateway queue only while the bridge enqueues into the
communication-channels queues served by the app server process, so under the
default in-process strategy inbound jobs were dropped with no error, no log and
no retry. The inbound path also logs receive / dropped-as-bot-authored / enqueued,
which is what made a dead socket indistinguishable from a lost job during QA.

#4977 (interim) — a bot token already served by a live session cannot open a
second socket. The real fix is hub-side: the hub derives `externalIdentifier`
from email-shaped credential keys, which Discord has none of, so every reconnect
inserts a duplicate channel row. Guarding here keeps the single-identify-per-bot
discipline until that is decided. The token is matched by SHA-256 fingerprint,
never stored or logged raw.

#4982 — provider description cut from 396 to 108 characters, inside the 68-132
range its six siblings occupy, so the card stops inflating its whole grid row;
literal backticks removed and ASCII arrows replaced with proper ones.

#4983 — the create-app template blocker now has an owner: #4986 tracks the
generated AGENTS.md hitting its 12 KiB budget at 49 enabled modules.

TC-CHANNEL-DISCORD-003 no longer claims to prove that Discord inbound works. It
passed by feeding the hub an invented @test-seed.local address for both
`externalIdentifier` and `to`, so `normalizeInboundDiscordMessage` never ran on
that path. The sender-identity assertions it pretended to make now live in a unit
test against a real MESSAGE_CREATE frame, and the end-to-end path is an explicit
`test.fixme` referencing #4975.

Package suite: 124 passing, up from 106.

* test(cli): restore headroom in the module-facts size guard

The `test` job was red on one assertion: the whole-repo module-facts JSON
render measured 3,504,040 bytes against a 3,500,000 cap. Measured on this
tree, the guard was already exhausted before this PR's module existed —
3,488,120 of 3,500,000 JSON bytes (99.66%) and 1,520,711 of 1,550,000
markdown bytes (98.11%) come from the 55 modules on develop.

A whole communication-channel provider costs 15,920 JSON and 9,239
markdown bytes (`channel_gmail` 6,886, `channel_imap` 6,798; the gap is a
gateway worker, a CLI command, a signed route and a subscriber, spread
proportionally across overrideTargets, extensionSurfaces, factSources and
ownedContracts — nothing duplicated). So both caps had stopped detecting
blow-ups and started rejecting whichever PR happened to add the next
module, of any size.

Raised to ~20% headroom above the current tree, with the measurement
recorded inline next to the three earlier raises. The guard keeps its
purpose: a real blow-up here is multiplicative — a duplicated provenance
payload, or a contribution body copied per resolution — not one
provider's worth of references. The delta cap (1,800,000, currently
1,527,947) still has 15% headroom and is unchanged.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test(channel-discord): make the AI auto-reply dormancy claim executable

Review round 2 (@pkarw, 2026-07-30) ended on the coverage verdict: the
provider suite "mocks away the central AI policy/runtime". The release
answered the capability half by de-scoping AI auto-reply to #4778, but
the coverage kept the shape the review objected to — a stubbed
`runAiAgentObject` under a test named "drafts and sends", which reads as
proof of a capability this release does not promise.

Two changes, no behaviour change:

- `ai-auto-reply.dormancy.test.ts` turns the PR description's prose claim
  ("nothing in the product writes the two arming keys") into a guard. It
  scans the package's own sources and fails if `aiAutoReplyEnabled` /
  `aiAgentId` are ever named outside the four files that read them, or by
  any widget, API route, setup hook, preset, CLI command or integration
  descriptor. Verified by violation: dropping a probe file naming a key
  under `widgets/` fails both assertions.

- The existing subscriber test says what its stub does and does not
  prove, and stops claiming a capability: the agent call is stubbed
  because `@open-mercato/ai-assistant` is a genuinely soft-optional peer
  (absent from dependencies/peerDependencies — hence `{ virtual: true }`),
  so importing the real runtime here would break the decoupling property
  the review verified. Under the real policy the `features: []` call this
  subscriber makes is refused for every chat-mode, feature-gated agent the
  repository ships; the coverage that drives that policy for real belongs
  with the capability, in #4778.

Package suite 127 passing, up from 124.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(create-app): record the measured cost of enabling channel_discord (#4983)

#4986 closed with #4989, which removed the hard overflow this comment used to
cite: the generated standalone root now sheds its inline module-fact index for a
pointer form instead of blowing the 12,288-byte target. Enabling the module here
was therefore re-tested rather than assumed, and it still does not pay off —
measured on this head, the generated classic root lands at 12,275 bytes with the
inline index intact, i.e. 13 bytes of headroom, so the next module enabled after
it drops the index to pointer form and fails the guard test that protects it.

Per packages/create-app/AGENTS.md the fallback is something to accept
deliberately or to pay for by reclaiming root bytes, and neither is this PR's
call to make. The comment now states the measurement and points at the guard
test instead of at a closed issue, which is what #4983 actually asked for: an
owner and a reason, not a bare TODO.

* fix(channel-discord): align the package version with the monorepo (0.6.7)

The base merge brought in the 0.6.6 → 0.6.7 bump, which touched every public
package except this one — it does not exist on `develop` yet, so the release
bump had nothing to rewrite here. `scripts/check-version-alignment` is exactly
the guard for that case and it went red on the merged head:
`@open-mercato/channel-discord@0.6.6 (expected 0.6.7)`.

Worth noting for the local gate: this guard lives in root `scripts/__tests__`,
which turbo never picks up, so `yarn test` is green while CI's separate
`yarn test:scripts` step fails. Verified here with `yarn test:scripts`
(475 passing) rather than by re-running `yarn test`.

Version-only change; no code, no dependency shape.

* test(cli): record the measured fact budgets behind the merged bc-guard caps

* chore(channel-discord): pin the two peers every sibling channel package pins

`yarn test:scripts` went red on the merged tree with two unmet peers for
`@open-mercato/channel-discord`: `@open-mercato/core` requires
`@open-mercato/ai-assistant`, and `@open-mercato/ui` requires `react-is`.

Neither is specific to this provider. Every sibling — `channel-gmail`,
`channel-imap`, `channel-apns`, `channel-expo`, `channel-fcm`, `checkout` — has
the identical pair pinned with the identical reasons; `channel-discord`'s
package.json declares exactly the same dependencies and peerDependencies as
`channel-apns`. It is absent from the allowlist only because it did not exist
when `develop` added the guard, so the merge is the first time the two met.

That is precisely the case the guard documents itself as handling: pre-existing
unmet peers are pinned so the gate blocks NEW ones rather than demanding an
unrelated cleanup first. Generated with the script's own `--update-allowlist`,
then reworded to match the siblings verbatim so the file stays readable and the
follow-up note keeps applying to every entry that shares it.

* test(channel-discord): make TC-CHANNEL-DISCORD-003 assert what its name claims

The spec was a `test.fixme` plus a second case that fed the hub an invented
`…@test-seed.local` address for both the channel identifier and the recipient.
That is why it stayed green through three live defects: a real Discord channel
carries `external_identifier = NULL` and a real Discord sender has no address,
so `normalizeInboundDiscordMessage` never ran on the asserted path.

Both things that blocked the honest version have landed. #5252 made the
`externalEmail` requirement conditional on the originating channel being
email-typed, and the test-seed `ingest-inbound` action drives the real
`ingest_inbound_message` command, which composes through
`messages.messages.compose` instead of inserting rows behind it.

So the spec now starts from a verbatim `MESSAGE_CREATE` frame, runs it through
the **real** `normalizeInboundDiscordMessage` — the same function the gateway
worker calls, not a re-implementation — and hands the result to the hub with no
address anywhere. It asserts the sender stays a snowflake, that the normalizer
invents nothing, and that a platform message and `MessageChannelLink` exist at
the other end. Before #4975 that last step failed validation, retried three
times and died in the queue while the channel still reported `Connected`.

The ceiling is stated in the file rather than implied: the transport is the
chat-flavoured test-seed adapter, because connecting a real Discord channel
needs a live bot token and a live credential probe. What is not stubbed is the
part that was broken — the frame, the normalizer, and compose validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(channel-discord): declare provider-native recipients so outbound actually works

Found in QA of this PR against a live Discord bot, on a tree that already has
both #5252 and #5261 merged. Posting a genuine Discord channel snowflake to
`POST /channels/{id}/test-send` still answers:

    HTTP 422  {"error":"Recipient must be a valid email address"}

#5261 built the mechanism that was supposed to fix #4976 — recipients are
validated against the adapter's declared `capabilities.recipientFormat` — but
nothing wired Discord into it. `grep recipientFormat` across the whole
repository finds the type in `lib/adapter.ts`, the check in
`lib/outbound-recipient.ts`, `'email'` in `lib/email-capabilities.ts`, and
nothing else. No adapter anywhere declares `'provider-native'`, so that branch
is unreachable in production and only its own unit tests — which pass a literal
`{ recipientFormat: 'provider-native' }` object — ever execute it.

The consequence is that #4976 is still live for the provider it was filed
against: `validateOutboundRecipient` falls through to the email default and
rejects every real Discord recipient.

The declaration belongs here rather than in the hub: the adapter owns the shape
of its own recipients. With it, the hub applies transport safety only (the
allowlist and length ceiling #5261 added, which is the part that matters —
Discord interpolates the recipient into `/channels/{recipient}/messages`) and
this adapter keeps treating the value as untrusted.

The capability-contract test did not catch this because it pins feature flags,
not `recipientFormat`; it now pins this too, so the branch cannot silently go
unreachable again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(channel-discord): post to the recipient the operator asked for

Second finding from QA of this PR against a live Discord bot, and the more
dangerous of the two because it looks like success.

`resolveTargetChannelId` read `metadata.discordChannelId`, then `conversationId`,
then `defaultChannelId`. It never read `metadata.to` — which is exactly where
the hub's `test-send` route puts the operator's recipient:

    metadata: { to: body.to, subject: ..., testSend: true }

So every test send went to `defaultChannelId` regardless of what was asked for,
and the endpoint answered `200 sent`. Proven live: posting with
`to: "999999999999999999"` (a channel id that does not exist) returned
`{"status":"sent","externalMessageId":"1541054518630817792"}`, and fetching that
id from Discord shows the message sitting in the default channel. An operator
test-sending to one channel gets a green result for a message that landed in
another; a recipient that is validated and then discarded is worse than one
that is rejected.

`conversationId` deliberately still outranks `metadata.to`: on a reply the
thread mapping is authoritative and a stray recipient must never redirect a
reply out of its own conversation. `metadata.to` decides only when there is no
conversation, which is the test-send case. Both orderings are pinned by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(runs): adopt PR #4391 — reconstruct execution plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(channel-discord): reject unsigned interactions before loading any candidate

The Interactions endpoint is unauthenticated by design, so any caller can POST
to it. It resolved every active Discord channel in the installation and
decrypted each one's credentials BEFORE it validated anything about the request,
so a POST with no headers and an empty body cost 1+N..1+2N database round-trips
and N AES decrypts, where N is the number of Discord channels across the whole
installation. The fail-closed semantics and the tenant pinning were correct; the
ordering was not, and the "constant cost" claim in the handler's doc comment
described an intent the code did not implement.

Two changes, both local:

- Hoist the request-only guards ahead of the candidate load. Timestamp freshness
  and the signature header's presence and hex shape depend on the request alone,
  so `screenInteractionRequest` decides them first and `resolveDiscordInteraction`
  only calls the candidate loader for a request that survives. The unsigned,
  malformed and replayed paths now touch the database zero times, which the new
  call-count assertions pin so the ordering cannot silently regress. The doc
  comment now describes what the code does.

- Narrow the candidate set by the `application_id` the body claims before the
  Ed25519 fan-out, so the fan-out runs over the channels of one Discord
  application instead of every Discord channel. This is a NARROWING and never an
  authorization decision: the value is read before any signature is verified, it
  can only ever shrink the set, and a forged `application_id` still fails the
  signature gate — asserted explicitly.

Credentials are also resolved once per (tenant, organization, user) scope rather
than once per channel row, so channels sharing a credential bag no longer each
pay for a decrypt.

Response bodies and status codes are unchanged for every input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(runs): mark discord-channel-provider Phase 2 complete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(channel-discord): declare threading honestly, honour the job's organization scope

Four review findings, all about the provider promising something it does not do.

- `capabilities.threading` was `true` on the strength of `convertOutbound`
  emitting a `message_reference` from `channelMetadata.replyToExternalId`. No hub
  producer writes that key into OUTBOUND metadata: it exists only on the inbound
  `NormalizedInboundMessage` shape, and the hub's outbound producers
  (`send-as-user.ts`, `deliver-outbound-message.ts`) write the email-shaped
  `inReplyTo` / `references`. The branch was unreachable in production, as a live
  bot confirmed in #5541. Declared `false` with the reason naming the missing
  producer, pinned by a contract test that also asserts the conversion still
  works, so the flag flips back with that producer and nothing else.

- The `defaultChannelId` help text still told operators the hub test-send
  endpoint validates its recipient as an email address and therefore could not
  exercise this. #4976 is fixed — #5261 built the mechanism and this branch
  declares `recipientFormat: 'provider-native'` — and QA sent a real message
  through it. (`helpText` is a plain English descriptor field across all seven
  providers; the module's locale files carry the connect-widget field labels,
  which are unchanged.)

- The spec's Adapter method map still declared `fileSharing: true` and
  `interactiveComponents: true` against code declaring both `false`, and gave the
  subscriber `metadata.id` as `discord-ai-auto-reply` while the code ships
  `channel_discord:ai-auto-reply`. Subscriber ids drive dedup, so an id copied
  out of the spec would have been read as a different subscriber and re-delivered
  handled events. Both corrected, with a changelog entry.

- `GatewayJobPayload.organizationId` was accepted and silently ignored: both the
  channel query and the reconciliation filtered on tenant alone, so a job scoped
  to one organization connected the whole tenant and — worse — reconciled away
  every sibling organization's live sockets, since none of their channels were in
  the set it compared against. Honoured now in both places, with the filter
  extracted to `buildGatewayChannelFilter` so the scoping is pinned directly.
  An explicit `organizationId: null` still means "no filter", not "tenant-wide
  rows only".

Carries the three review nits too, since two of them touch the same files:

- `integration.version` `0.6.6` → `1.0.0`. The old value was copied from the
  package's monorepo version, so it read as a claim to track it and had already
  drifted (the package is at 0.6.7). A comment now says what the field is for.
- `start-gateway` used the positional-pairing `parseArgs`, where a bare `--flag`
  shifts every remaining pair. It now uses `parseFlagsAndValues`, which the
  sibling command already used, and the old parser is deleted.
- `draftAndSendEasyReply` no longer takes the `channel` it never read, instead of
  suppressing the warning with `void channel`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(runs): mark discord-channel-provider Phases 3 and 4 complete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): add the Discord provider's ACL feature titles to the catalog

`develop` gained a guard that walks every discovered `acl.ts` and requires each
declared feature to carry a matching English title in the auth ACL translation
catalog. This branch adds a module the guard had never seen, so merging develop
turned it red: `channel_discord.view` and `channel_discord.configure` had no
catalog entry at all, which in the product means the permissions UI would render
the raw feature id instead of a name.

Adds both titles, following the convention the five sibling channel providers
already use in this catalog: English and Polish translated, German, Spanish and
Korean carrying the English fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(runs): mark discord-channel-provider Phase 5 complete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(channel-discord): dispatch slash commands and components past the deferred ack (#4663)

The Interactions endpoint verified Ed25519 signatures and then answered every
application command and message component with a bare deferred acknowledgement.
Nothing replaced it, so an operator who registered a slash command saw Discord's
"thinking…" state forever, and the interaction never reached the hub. That was
disclosed as deferred scope on #4391 and tracked as #4663; this closes it.

Discord gives the endpoint three seconds, so the route still answers with a
deferred ack — but now it hands the verified interaction to a dispatch worker
first, and the ack is only returned once the hand-off is queued. The worker does
the two slow halves: it normalizes the interaction into the hub's existing
`communication-channels-inbound` queue and replaces the placeholder over
Discord's interaction-webhook endpoints.

The hub contract is untouched. The interaction is presented as a synthesized
Discord message object and reaches the hub through `adapter.normalizeInbound`,
so a slash command lands in the same conversation, under the same tenant scope,
with the same `(channel_id, external_message_id)` dedup discipline as anything
typed in the channel. `normalizeInboundDiscordMessage` adds the interaction id,
type, command name and component custom id to `channelMetadata` so a consumer
can tell a command from a chat message without re-parsing `channelPayload`.

- `APPLICATION_COMMAND`, `MESSAGE_COMPONENT` and `MODAL_SUBMIT` are dispatched.
- Autocomplete is answered synchronously with an empty choice list, the only
  response Discord accepts for it — a deferred ack there is rejected.
- Any other type, and any payload missing the follow-up token, channel or
  invoking user, gets a visible ephemeral reply. The endpoint never returns a
  deferred ack it cannot redeem.
- The follow-up edits the original response first, because that is the call that
  ends the "thinking…" state, and falls back to a follow-up POST when the
  original is gone. Neither carries the bot token: the interaction token in the
  URL is the credential. The queue payload carries a credential *scope*, never a
  secret, because the local queue strategy persists payloads to disk as JSON.
- `register-slash-commands` ships as a CLI command. Guild-scoped, because guild
  registrations take effect immediately while global ones take up to an hour,
  and the call replaces the guild's list so re-running converges.

`interactiveComponents` flips back to `true` in this same commit, guarded by a
parity test in `capabilities.test.ts` that drives the whole path — dispatch, hub
job, follow-up — rather than asserting the flag against itself. The capability
profile cannot drift ahead of the implementation again without that test failing.

The spec's setup section still pointed operators at
`/api/communication_channels/webhook/discord` for the Interactions Endpoint URL;
the shipped route is `/api/channel_discord/interactions`, and anyone who copied
the old value would have failed the PING handshake. Fixed, along with the
capability table, the module file table and the reused-hub-routes list.

Refs #4391

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(channel_discord): ship the de-scoped AI auto-reply (#4778)

PR #4391's re-review established that the AI auto-reply subscriber could not
work against anything the repository shipped, and that no operator could turn
it on. It called `runAiAgentObject` with `features: []`, every shipped agent was
chat-mode and feature-gated, and nothing wrote the two channel-state keys that
arm it. The feature was de-scoped and tracked here. This closes all four halves
of that finding.

A production agent. `channel_discord/ai-agents.ts` declares
`channel_discord.auto_reply`: object-mode with its own
`{ reply, summary, confidence, requiresHuman }` output schema, `readOnly`,
`mutationPolicy: 'read-only'`, no allowed tools, gated on the real feature
`channel_discord.ai_auto_reply.run`. Object mode is what makes propose-only
mechanical rather than advisory — the runtime discards the resolved tool map
before calling `generateObject`, so no tool is reachable from an inbound Discord
message at all.

A service principal with real features. `lib/ai-service-principal.ts` resolves
the tenant's channel-bot user and loads its real ACL, falling back to a single
code-declared, non-data grant when no such user exists. `isSuperAdmin` is
clamped to false on both paths so a message from a public server can never
borrow a super-admin's ACL.

A configuration path. `GET`/`PUT /api/channel_discord/channels/{id}/ai-auto-reply`
behind a `CrudForm` page, reached from an AI auto-reply panel on the Discord
integration's detail page. Zod-validated, scoped and authorized through the
hub's own org-scope and channel-access guards, mutation-guarded, optimistic
-locked through the DI-aware seam, and persisted by a command that merges into
`channelState` so a settings save cannot clobber the gateway's resume cursor.
Enabling is refused when the AI peer is absent or the agent is one the runtime
would reject.

A proposal surface. The `complex` tier files an internal message carrying the
drafted reply with approve/dismiss actions instead of logging and returning;
approving composes the public reply attributed to the approving operator. With
nobody assigned to the conversation the proposal is stored as a draft and the
miss is logged.

Auto-send now needs three independent yeses — the regex tiering says easy, the
model sets `requiresHuman: false`, and its confidence clears 0.6.

`@open-mercato/ai-assistant` is declared as an optional peer; the runtime
coupling stays a dynamic import behind a DI presence check. The dormancy
contract is replaced by an armed contract, and a new spec drives the REAL
`agent-policy` / `agent-runtime` with only the model call stubbed. The provider
advertises AI auto-reply again and the `ai` tag is back — in the same change
that makes it true.

Refs #4391
Closes #4778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(channel-discord): move the API routes off the legacy method-folder layout

The provider shipped its three routes under `api/get/`, `api/put/` and
`api/post/`. That layout is the minority convention here (25 route files
against 556 using `api/<path>/route.ts` with exported method handlers) and
the generator itself scans it under a `// Legacy per-method` heading.

Worse, a `route.ts` placed inside a method folder is picked up TWICE: once by
the `route-file` scan, which walks all of `api/` including the method dirs,
and once by the per-method scan, whose include filter accepts any `.ts`. Both
entries resolve to the same `metadata.path`, so the manifest carried two
registrations for every one of these routes.

  api/post/interactions/route.ts              -> api/interactions/route.ts
  api/get/channels/[id]/ai-auto-reply/route.ts \
  api/put/channels/[id]/ai-auto-reply/route.ts -> api/channels/[id]/ai-auto-reply/route.ts

GET and PUT now live in one file with one `metadata` block declaring each
method's own guard, and one merged `openApi`. The generated shard confirms
the result: two entries, `kind: "route-file"`, `methods: ["POST"]` and
`["GET","PUT"]`.

No URL, guard, payload or behaviour changes — `/api/channel_discord/interactions`
and `/api/channel_discord/channels/{id}/ai-auto-reply` are byte-identical, and
the interactions route keeps its explicit `metadata.path` because that URL is
operator-facing (it goes into Discord's Interactions Endpoint field); it now
documents the derived path rather than overriding it.

Also drops the legacy `export default POST` (the path-based layout reads named
exports), and tightens the armed-contract test: it asserted the configure
feature appeared somewhere in the write route's file, which a merged GET+PUT
file would satisfy even if only the read half were guarded. It now matches the
PUT metadata block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(channel-discord): authorize the auto-reply agent at save time, and surface a dormant channel

Addresses the three findings from the 2026-08-25 re-review of #4391.

Medium — the settings route checked that the chosen agent was SHAPED right
(object-mode, so `runAiAgentObject` would take it) and stopped there. The
runtime asks a second question: `checkAgentPolicy` runs the agent's
`requiredFeatures` against the principal from `lib/ai-service-principal.ts`,
which carries one grant unless the tenant created a channel-bot user with a
wider role. An operator could pick an agent from another module, the PUT would
succeed, the UI would read "Auto-reply on", and every inbound message would be
denied inside a background subscriber — the dormancy failure #4778 was filed
for, wearing a green tag.

  - PUT resolves that same principal and refuses the save, naming the missing
    grants in the response body. The comparison runs through the platform's own
    `authorizeFeatures`, one feature at a time, so wildcard role grants
    (`customers.*`, `*`) resolve exactly as the runtime resolves them — a
    hand-rolled `Set.has` would reject a bot user whose role legitimately
    covers the agent.
  - Channel authorization moved ahead of the agent checks, so a caller who may
    not manage the channel gets the masked 404 before anything about the agent
    registry is disclosed.
  - GET marks every offered agent `invocable` / `missingFeatures`. The picker
    labels the ones the principal cannot invoke rather than silently offering
    them, and the form defaults to one it can.

A save-time check goes stale the moment a role is edited, and the subscriber
degrades every failure to a no-op by design, so "armed but answering nothing"
also had to become observable rather than merely rarer:

  - Each failure writes `channelState.aiAutoReplyLastError`, through
    `lib/channel-state-store.ts` so the single-writer contract on the arming
    keys still holds, and the next successful attempt clears it. Repeats of the
    same reason do not rewrite the row, so a broken channel does not generate a
    row update per inbound message.
  - The settings page renders it as a banner; the integration panel flips the
    channel's tag from "Auto-reply on" to "Auto-reply failing".
  - The hub's own `channel.lastError` is deliberately untouched: the hub owns it
    for delivery and polling and clears it on a successful poll, so sharing it
    would let the two failure kinds erase each other.
  - New `lib/failure-reason.ts` redacts the stored reason. It leaves the log and
    enters the product, and an upstream SDK that echoes an Authorization header
    would otherwise put a live token in a JSONB column and on an operator's
    screen.

Low — the AI auto-reply panel was an N+1 from the browser: it listed channels
and then called the per-channel settings route once per channel, and each of
those responses rebuilt the whole agent registry to render two booleans. New
`GET /api/channel_discord/ai-auto-reply/channels` answers the panel in one
query and never touches the directory; a test asserts that so the N+1 cannot
creep back. The route pins its own `metadata.path`, the way the interactions
route does, so the collection cannot be mistaken for a channel id.

Low — `lib/interactions-queue.ts` read as a blanket "no credential travels on
this payload" guarantee. True for the bot token, not for `interaction.token`,
which can post as the application on its own and does land on disk under the
local queue strategy. The comment now says so, with the 15-minute lifetime that
bounds it.

Tests: 258 unit tests in the package, up from 210 — a route-level arming matrix
(refused agent, provider agent under the bare service principal, foreign agent
once granted, wildcard grant, disarm, masked 404, absent peer, wrong shape), the
feature comparison against the real `authorizeFeatures`, the marker's
write/clear/dedup/truncation/scope rules, redaction, and proof that a marker
write can never escalate a degraded no-op into a thrown handler.

Spec and its changelog updated. No schema change, no hub contract change; the
two new state keys ride the existing `channel_state` JSONB additively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(channel-discord): drop the now-unused isDiscordEligibleAgentId

`findDiscordEligibleAgent` replaced its only call site in the same change that
added the requiredFeatures check, because that caller needs the agent's
`requiredFeatures` too and the boolean form meant loading the agent registry
twice. Leaving the wrapper behind would be dead exported code in a lib the
package does not re-export publicly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: wojciechszyjka <wojciechszyjka@users.noreply.github.com>
Co-authored-by: Piotr Karwatka <piotr.karwatka@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant