Skip to content

Tech spec: threaded tweet replies #148

Description

@guidefari

Threaded Tweet Replies

Summary

Add internal threaded replies to www tweet pages by continuing to model tweets and replies as rows in posts with type = 'micro'.

The recommended model is a self-referential thread shape on posts: direct parent, root post, and depth. Top-level tweet creation stays restricted to creator/editor/admin roles. Replies use community mode: any authenticated user can reply through a dedicated reply endpoint. The server owns slug generation and all thread fields.

Context / Current State

  • apps/www/src/routes/tweet/index.tsx renders the tweet feed from useMicroPosts(5).
  • apps/www/src/routes/tweet/$slug.tsx loads one micro post from GET /content/posts/micro/{slug}.
  • apps/www/src/routes/new/-TweetCapturePage.tsx currently creates tweet slugs on the client because POST /content/post requires slug.
  • apps/vps/src/db/post.schema.ts has postsTable with content fields, type, musicEntityType, and musicEntityId, but no thread fields.
  • apps/vps/src/services/post.service.ts owns getMicroPosts, getMicroPostBySlug, create, update, micro/editorial refinement, and MDX compilation.
  • apps/vps/src/routes/content/content.routes.ts and content.handlers.ts expose the current Hono OpenAPI content routes.
  • Existing post validation already allows micro posts with either title or content.
  • Existing route tests include blackbox HTTP tests in apps/vps/src/http/routes.blackbox.test.ts; route files should be tested through integration/blackbox behavior, not route unit tests.

Goals

  • Let any authenticated user reply to an existing tweet/micro post.
  • Keep top-level tweet creation restricted to creator/editor/admin users.
  • Keep replies out of the main /tweet feed by default.
  • Support direct replies and deeper nested thread data at the persistence/API level.
  • Let v1 UI render replies simply while storing enough structure for nested UI later.
  • Move slug ownership for new tweet/reply creates to the server.
  • Preserve existing creator/admin edit behavior for created replies.

Non-Goals

  • No X/Twitter API integration.
  • No real-time replies.
  • No notification system.
  • No moderation queue.
  • No delete/tombstone workflow in v1 unless the project decides to add reply deletion in the same pass.
  • No full nested reply composer UI required in v1; the data model supports it, but the page can render flat initially.

Invariants

type TopLevelTweetThreadFields = {
  readonly parentPostId: null
  readonly rootPostId: null
  readonly depth: 0
}

type ReplyThreadFields = {
  readonly parentPostId: string
  readonly rootPostId: string
  readonly depth: number
}
  • A top-level tweet has type = 'micro', parentPostId = null, rootPostId = null, and depth = 0.
  • A reply has type = 'micro', parentPostId = parent.id, rootPostId = parent.rootPostId ?? parent.id, and depth = parent.depth + 1.
  • Replies can only target type = 'micro' posts.
  • Reply endpoint callers cannot set slug, parentPostId, rootPostId, depth, or creatorIds.
  • parentPostId, rootPostId, and depth are immutable after creation.
  • The main tweet feed returns only top-level tweets unless an explicit query asks for replies.
  • Full thread reads return the root and descendants with stable oldest-first ordering for descendants.
  • Editing uses existing post creator/admin ownership rules.
  • Community reply permission means any authenticated user can create a reply, but that permission must not allow top-level post creation.

Design Constraints

  • Production database change must be additive and nullable for existing rows.
  • Database column names should be snake_case while TypeScript fields stay camelCase.
  • Existing www create flow currently sends slugs, so POST /content/post should tolerate supplied slugs while allowing server-generated slugs for new clients.
  • New reply create should use a dedicated endpoint, not generic POST /content/post, so community permissions cannot accidentally create top-level tweets.
  • Keep using the existing Hono OpenAPI route style for content routes unless this is intentionally folded into the Effect HTTP migration later.
  • Keep using PostService as the Service Module for post orchestration.
  • Expected failures stay in Effect's typed error channel inside PostService.
  • HTTP bodies are parsed with the established Zod OpenAPI route schemas before service logic receives them.

Alternatives Considered

Option 1: Separate post_replies Table

type PostReplyRow = {
  readonly id: string
  readonly parentPostId: string
  readonly rootPostId: string
  readonly creatorId: string
  readonly content: string
  readonly createdAt: Date
  readonly updatedAt: Date
}

Pros:

  • Replies can have a narrower schema than posts.
  • It avoids making all posts rows thread-aware.
  • Deletion/tombstone behavior can be designed independently.

Cons:

  • Duplicates creator, MDX, slug, and content behavior already owned by posts.
  • Forces separate rendering and API projections for tweet-like content.
  • Makes it harder to link to a reply as a normal micro post.
  • Adds a second content lifecycle too early.

Option 2: parentPostId Only

type PostThreadFields = {
  readonly parentPostId: string | null
}

Pros:

  • Smallest schema change.
  • Direct replies are easy to query.
  • Simple mental model.

Cons:

  • Full thread reads need recursive SQL or multiple round trips.
  • Sorting a whole thread is more expensive and more complex.
  • Every descendant lookup starts from adjacency traversal.
  • Nested UI is harder to build later.

Option 3: parentPostId, rootPostId, and depth on posts

type PostThreadFields =
  | { readonly _tag: 'TopLevel'; readonly parentPostId: null; readonly rootPostId: null; readonly depth: 0 }
  | { readonly _tag: 'Reply'; readonly parentPostId: string; readonly rootPostId: string; readonly depth: number }

Pros:

  • Reuses existing post/micro-post system.
  • Direct replies and full threads are both efficient.
  • Thread invariants are local to PostService.createReply.
  • Existing tweet pages and post creator model remain useful.
  • Supports nested UI later without another migration.

Cons:

  • rootPostId and depth are denormalized and must be derived by the server.
  • Requires immutable field rules on update.
  • Adds thread fields to all posts, even editorial posts where they should stay null.

Recommendation

Use Option 3.

Replies should be micro posts in posts with parentPostId, rootPostId, and depth. The dedicated reply endpoint derives those fields from the parent. The generic create endpoint can be improved to generate slugs when omitted, but community reply writes should not go through the generic create route.

Proposed Design

Persistence Shape

export const postsTable = pgTable(
  'posts',
  {
    ...postContentFields,
    title: varchar({ length: 255 }),
    content: text(),
    type: postTypeEnum(),
    musicEntityType: text('music_entity_type'),
    musicEntityId: uuid('music_entity_id'),
    parentPostId: uuid('parent_post_id').references((): AnyPgColumn => postsTable.id, {
      onDelete: 'set null'
    }),
    rootPostId: uuid('root_post_id').references((): AnyPgColumn => postsTable.id, {
      onDelete: 'set null'
    }),
    depth: integer('depth').notNull().default(0)
  },
  (table) => [
    index('posts_slug_idx').on(table.slug),
    index('posts_music_entity_idx').on(table.musicEntityType, table.musicEntityId),
    index('posts_type_created_idx').on(table.type, table.createdAt),
    index('posts_tags_gin_idx').using('gin', table.tags),
    index('posts_parent_created_idx').on(table.parentPostId, table.createdAt),
    index('posts_root_created_idx').on(table.rootPostId, table.createdAt)
  ]
)

If Drizzle self-reference typing needs it, import AnyPgColumn from drizzle-orm/pg-core and use the self-reference callback form shown above.

Suggested database checks:

CHECK (depth >= 0)
CHECK (
  (parent_post_id IS NULL AND root_post_id IS NULL AND depth = 0)
  OR
  (parent_post_id IS NOT NULL AND root_post_id IS NOT NULL AND depth > 0)
)

The service still owns the stronger invariant that a reply's rootPostId equals parent.rootPostId ?? parent.id.

Domain Model and Types

type MicroPostId = string
type MicroPostSlug = string

type ThreadPosition =
  | {
      readonly _tag: 'TopLevel'
      readonly parentPostId: null
      readonly rootPostId: null
      readonly depth: 0
    }
  | {
      readonly _tag: 'Reply'
      readonly parentPostId: MicroPostId
      readonly rootPostId: MicroPostId
      readonly depth: number
    }

type ReplyableMicroPost = SelectPost & {
  readonly id: MicroPostId
  readonly slug: MicroPostSlug
  readonly type: 'micro'
  readonly draft: false
  readonly depth: number
  readonly rootPostId: MicroPostId | null
}

type CreateMicroPostReplyInput = {
  readonly parentSlug: MicroPostSlug
  readonly actor: {
    readonly id: string
    readonly role: string | null | undefined
  }
  readonly title: string | null | undefined
  readonly content: string | null | undefined
}

type CreateMicroPostReplyData = {
  readonly title?: string | null
  readonly content?: string | null
}

type MicroPostThread = {
  readonly root: SelectMdxCompiledMicroPost
  readonly focus: SelectMdxCompiledMicroPost
  readonly posts: ReadonlyArray<SelectMdxCompiledMicroPost>
  readonly pagination: PaginationMetadata
}

Expected Failures

Use existing errors where they fit. Add narrow errors only if current errors are not semantically precise enough.

type CreateReplyError =
  | DatabaseError
  | NotFoundError
  | ValidationError
  | ConflictError
  | UnauthorizedError

Expected cases:

  • Parent slug does not exist: NotFoundError.
  • Parent exists but is not type = 'micro': ValidationError or a new ParentPostNotReplyableError.
  • Parent is a draft and actor cannot view it: UnauthorizedError or NotFoundError, matching existing content visibility policy.
  • Reply body has no title or content: existing ValidationError from validatePostData.
  • Slug collision: existing ConflictError, with retry around slug generation if desired.
  • Database insert/query fails: existing DatabaseError.

Types, Interfaces, and APIs

PostService

export interface PostService {
  readonly getMicroPosts: (options: {
    readonly limit: number
    readonly offset: number
    readonly includeReplies?: boolean
  }) => Effect.Effect<
    { readonly data: SelectMdxCompiledMicroPost[]; readonly pagination: PaginationMetadata },
    DatabaseError,
    SentryService
  >

  readonly getMicroPostReplies: (
    parentSlug: string,
    options: { readonly limit: number; readonly offset: number }
  ) => Effect.Effect<
    { readonly data: SelectMdxCompiledMicroPost[]; readonly pagination: PaginationMetadata },
    DatabaseError | NotFoundError,
    SentryService
  >

  readonly getMicroPostThread: (
    slug: string,
    options: { readonly limit: number; readonly offset: number }
  ) => Effect.Effect<MicroPostThread, DatabaseError | NotFoundError, SentryService>

  readonly createMicroPostReply: (
    input: CreateMicroPostReplyInput
  ) => Effect.Effect<SelectMdxCompiledMicroPost, CreateReplyError, SentryService>
}

HTTP Routes

Top-level feed, unchanged URL with refined semantics:

GET /content/posts/micro?limit=5&offset=0

Default response excludes replies:

type GetMicroPostsQuery = {
  readonly limit: number
  readonly offset: number
  readonly includeReplies?: boolean
}

Direct replies:

GET /content/posts/micro/{slug}/replies?limit=20&offset=0
type MicroPostRepliesResponse = PaginatedResponse<SelectMdxCompiledMicroPost>

Full thread:

GET /content/posts/micro/{slug}/thread?limit=100&offset=0
type MicroPostThreadResponse = {
  readonly root: SelectMdxCompiledMicroPost
  readonly focus: SelectMdxCompiledMicroPost
  readonly posts: ReadonlyArray<SelectMdxCompiledMicroPost>
  readonly pagination: PaginationMetadata
}

Create reply:

POST /content/posts/micro/{parentSlug}/replies
{
  "content": "yeah, exactly",
  "title": null
}
const createMicroPostReplySchema = z
  .object({
    title: z.string().nullable().optional(),
    content: z.string().nullable().optional()
  })
  .strict()

Server-derived fields:

type ServerDerivedReplyFields = {
  readonly slug: string
  readonly type: 'micro'
  readonly parentPostId: string
  readonly rootPostId: string
  readonly depth: number
  readonly creatorIds: readonly [string]
}

Generic post create should allow server-generated slugs:

type CreatePostRequest = {
  readonly slug?: string
  readonly title?: string | null
  readonly content?: string | null
  readonly description?: string
  readonly thumbnailUrl?: string
  readonly draft?: boolean
  readonly tags?: readonly string[]
  readonly type?: 'post' | 'micro' | null
  readonly musicEntityType?: 'album' | 'track' | 'playlist' | null
  readonly musicEntityId?: string | null
  readonly creatorIds?: readonly string[]
}

www should eventually stop sending slugs for top-level tweets, but the API can continue accepting supplied slugs because the current client already sends them.

Seams, Boundaries, Adapters, and Implementations

  • content.routes.ts is the inbound HTTP schema/parser and OpenAPI boundary.
  • content.handlers.ts translates authenticated Hono context into service input.
  • PostService owns thread write policy, community reply permission, parent lookup, slug generation, transaction sequencing, and typed failures.
  • post.schema.ts owns persistence shape and protocol schemas exported through @gbfm/vps/schemas.
  • post-thread.ts can be added as a small pure Domain Module only if the derivation or invariants start spreading. Otherwise, keep the pure derivation local to post.service.ts.
  • www/src/lib/http.ts owns React Query hooks and protocol calls.
  • www/src/routes/tweet/$slug.tsx owns page composition.
  • TweetReplyComposer owns reply input state and mutation UX.
  • TweetReplyList owns reply rendering and loading states.

Call Stacks and Data Flow

Current / Old Flow

Top-level tweet creation today:

TweetCapturePage form state
  -> client normalizeSlugBase + Date.now suffix
  -> fetcher POST /content/post
  -> Hono route schema parses CreatePostRequest with required slug
  -> content.handlers.createPost
  -> PostService.create(postData, creatorIds)
  -> normalizePostData
  -> validatePostData
  -> db transaction insert posts + post_creators
  -> SelectPost response
  -> www navigates to /tweet/$slug

Tweet feed today:

useMicroPosts
  -> GET /content/posts/micro
  -> content.handlers.getMicroPosts
  -> PostService.getMicroPosts
  -> getAllEffect({ type: 'micro' })
  -> db select all micro posts sorted desc
  -> buildPostWithPreloadedCreators + MDX compile
  -> TweetListCard renders every micro post

Proposed / New Flow

Community reply create:

TweetReplyComposer form state
  -> fetcher POST /content/posts/micro/{parentSlug}/replies
  -> createMicroPostReplySchema parses title/content only
  -> betterAuthMiddleware provides authenticated user
  -> content.handlers.createMicroPostReply maps context to CreateMicroPostReplyInput
  -> PostService.createMicroPostReply
  -> load parent by slug
  -> refine parent as replyable micro post
  -> derive rootPostId and depth
  -> generate server slug
  -> normalizePostData({ type: 'micro', title, content })
  -> validatePostData
  -> db transaction insert posts + post_creators
  -> buildPostWithCreators
  -> SelectMdxCompiledMicroPost response
  -> www invalidates replies/thread queries

Direct replies read:

useMicroPostReplies(parentSlug)
  -> GET /content/posts/micro/{slug}/replies
  -> route query parser limit/offset
  -> PostService.getMicroPostReplies
  -> load parent by slug or fail NotFoundError
  -> db count where parentPostId = parent.id
  -> db select where parentPostId = parent.id order by createdAt asc
  -> preload creators
  -> MDX compile bounded concurrency
  -> PaginatedResponse<SelectMdxCompiledMicroPost>

Full thread read:

useMicroPostThread(slug)
  -> GET /content/posts/micro/{slug}/thread
  -> PostService.getMicroPostThread
  -> load focus by slug or fail NotFoundError
  -> rootId = focus.rootPostId ?? focus.id
  -> load root by id
  -> db count/select descendants where rootPostId = rootId order by createdAt asc
  -> include root separately in response
  -> compile posts
  -> MicroPostThreadResponse

Top-level feed read:

useMicroPosts
  -> GET /content/posts/micro
  -> PostService.getMicroPosts({ includeReplies: false })
  -> db where type = 'micro' and parentPostId is null
  -> TweetListCard renders only top-level tweets

Failure Flow

POST reply
  -> parent slug missing
  -> PostService returns NotFoundError
  -> runEffect maps to 404
  -> www toast: reply target not found
POST reply
  -> parent type != micro
  -> PostService returns ValidationError or ParentPostNotReplyableError
  -> runEffect maps to 422
  -> www toast: cannot reply to this content
POST reply
  -> unauthenticated
  -> betterAuthMiddleware rejects
  -> HTTP 401
  -> www shows sign-in CTA or auth prompt
POST reply
  -> blank title and content
  -> validatePostData fails with ValidationError
  -> HTTP 422
  -> composer keeps draft text and renders toast
PATCH existing reply
  -> caller is not reply creator and not admin
  -> requireCreatorOrAdmin returns UnauthorizedError
  -> HTTP 401/403 using existing route mapping

Retry / Cancellation / Idempotency Flow

  • Existing fetcher calls do not expose caller-owned cancellation into PostService; this spec does not add a new cancellation seam.
  • UI must disable the reply submit button while the mutation is pending.
  • The strict idempotency design would add an Idempotency-Key header and a persisted replay table scoped by actor plus parent plus key.
  • V1 can intentionally defer that table because existing content creates are not idempotent and reply duplication is low consequence, but that is a known duplicate-risk tradeoff.
  • If strict retry safety is required before implementation, add post_create_requests before shipping reply creation.

Strict idempotency option:

type PostCreateRequestRow = {
  readonly key: string
  readonly actorUserId: string
  readonly parentPostId: string | null
  readonly postId: string
  readonly createdAt: Date
}
POST reply with Idempotency-Key
  -> transaction checks actor + parent + key
  -> existing replay returns original post
  -> missing replay inserts post and replay row together

Observability Flow

Use existing Effect spans/logs around PostService methods. Add safe fields only:

type ReplyTelemetryFields = {
  readonly operation: 'post.createMicroPostReply'
  readonly parentSlug: string
  readonly parentPostId?: string
  readonly rootPostId?: string
  readonly actorUserId: string
  readonly depth?: number
  readonly errorTag?: string
}

Do not log raw reply content, title, request body, session object, or unknown caught values.

Files to Add / Change / Delete

Change: apps/vps/src/db/post.schema.ts

Owns:

  • Add parentPostId, rootPostId, and depth columns.
  • Add indexes for direct replies and full thread reads.
  • Extend select/openapi schemas with thread fields.
  • Make create request slug optional at the protocol schema layer.
  • Add reply create schema if colocated here.

Add: apps/vps/drizzle/<next>_threaded_tweet_replies.sql

Owns:

  • Add nullable thread columns.
  • Add depth default.
  • Add indexes.
  • Add check constraints if practical with current Drizzle generation.

Change: apps/vps/src/services/post.service.ts

Owns:

  • Filter getMicroPosts to top-level tweets by default.
  • Add direct reply read.
  • Add full thread read.
  • Add reply create orchestration.
  • Derive thread fields server-side.
  • Reject thread field mutation in update.
  • Generate slug when missing for generic create.

Optional Add: apps/vps/src/services/post-thread.ts

Owns pure derivation if it would otherwise spread:

export const deriveReplyThreadFields = (parent: ReplyableMicroPost): ReplyThreadFields => ({
  _tag: 'Reply',
  parentPostId: parent.id,
  rootPostId: parent.rootPostId ?? parent.id,
  depth: parent.depth + 1
})

Skip this file if the invariant remains local to one function.

Change: apps/vps/src/routes/content/content.routes.ts

Owns:

  • getMicroPostReplies route.
  • getMicroPostThread route.
  • createMicroPostReply route.
  • includeReplies query option if needed.
  • OpenAPI response schemas.

Change: apps/vps/src/routes/content/content.handlers.ts

Owns:

  • Parse route params/query/body through OpenAPI route validation.
  • Pull user from auth middleware for reply creation.
  • Call new PostService methods.

Change: apps/vps/src/routes/content/content.index.ts

Owns:

  • Register the new content routes.

Change: apps/www/src/lib/http.ts

Owns:

  • useMicroPostReplies(parentSlug, limit).
  • useMicroPostThread(slug, limit) if full thread is used by the page.
  • useCreateMicroPostReply(parentSlug) mutation.
  • Query invalidation key conventions.

Change: apps/www/src/routes/tweet/$slug.tsx

Owns:

  • Render reply composer for authenticated users.
  • Render sign-in CTA for unauthenticated users.
  • Render replies below the root tweet.
  • Keep existing edit/actions behavior for the root tweet.

Add: apps/www/src/components/TweetReplyComposer.tsx

Owns:

  • Reply text state.
  • Submit button pending state.
  • Mutation trigger.
  • Error/success toast behavior.

Add: apps/www/src/components/TweetReplyList.tsx

Owns:

  • Reply loading/error/empty states.
  • Direct reply rendering.
  • Load-more trigger if paginated in v1.

Add or Change Tests

  • apps/vps/src/services/post.service.test.ts for pure validation and thread field mutation rejection if kept as pure helpers.
  • apps/vps/src/http/routes.blackbox.test.ts or a new blackbox route test file for HTTP reply behavior.
  • apps/www component tests only if the project already has a practical seam for them; otherwise rely on typecheck plus manual/E2E for the page flow.

RGR TDD Test Plan

Slice 1: Persistence shape compiles

  • Red: Add a focused schema/type expectation that a SelectMdxCompiledMicroPost can carry thread fields.
  • Green: Add nullable columns and exported schemas.
  • Refactor: Keep field names camelCase in TS and snake_case in SQL.

Slice 2: Feed excludes replies

  • Red: Service or HTTP blackbox test creates one top-level micro post and one reply-shaped micro post, then asserts GET /content/posts/micro returns only the top-level post.
  • Green: Add parentPostId IS NULL to the default micro query.
  • Refactor: Add includeReplies only if there is a real caller.

Slice 3: Server derives reply thread fields

  • Red: Service test or integration test creates a reply to a root and asserts parentPostId, rootPostId, and depth are derived, ignoring any client-supplied thread fields.
  • Green: Add createMicroPostReply derivation and insert.
  • Refactor: Extract deriveReplyThreadFields only if useful.

Slice 4: Community reply permission does not broaden top-level create

  • Red: HTTP blackbox test shows an authenticated normal user can POST /content/posts/micro/{slug}/replies but cannot create a top-level tweet through POST /content/post if that route is supposed to stay creator/editor/admin.
  • Green: Put community permission only on the reply route.
  • Refactor: Keep auth checks explicit and local.

Slice 5: Reply creation rejects invalid parents

  • Red: HTTP blackbox tests for missing parent and non-micro parent.
  • Green: Add parent lookup and type = 'micro' refinement.
  • Refactor: Use existing NotFoundError and ValidationError unless a precise new error is warranted.

Slice 6: Direct replies read oldest-first

  • Red: Create two replies to one parent and one reply to another parent. Assert GET /content/posts/micro/{slug}/replies returns only direct replies ordered by createdAt ASC.
  • Green: Add direct reply query with count and pagination.
  • Refactor: Share creator preload/compile path with existing post list logic if it remains readable.

Slice 7: Full thread read returns root, focus, and descendants

  • Red: Create root, reply, nested reply. Assert GET /content/posts/micro/{replySlug}/thread returns root, focus reply, and descendants for the same root.
  • Green: Add root derivation and descendants query by rootPostId.
  • Refactor: Keep response projection explicit.

Slice 8: Update cannot mutate thread fields

  • Red: Attempt PATCH /content/posts/{slug} with parentPostId, rootPostId, or depth and assert the persisted thread position remains unchanged or the request rejects.
  • Green: Omit/reject immutable thread fields in update schema/service.
  • Refactor: Prefer rejecting unknown/immutable fields in mutating schemas for the changed route.

Slice 9: Server generates slugs

  • Red: POST /content/post without slug creates a post with a stable non-empty slug; reply create also returns a generated slug and rejects body-level slug.
  • Green: Use existing toSlug for top-level creates and reply or content-derived text for replies.
  • Refactor: Move slug generation into a small helper only if multiple call sites duplicate it.

Slice 10: www reply UX

  • Red: E2E or component-level seam, if available, verifies signed-in user can submit a reply and sees it in the reply list.
  • Green: Add TweetReplyComposer, hooks, and invalidation.
  • Refactor: Keep route page small by composing reply components.

Risks and Open Questions

  • Should full thread reads paginate all descendants, or should v1 cap thread size without pagination?
  • Should replies to drafts be blocked as 404 or 403? The answer should follow the existing draft visibility model.
  • Should reply content require content, or keep the existing micro-post rule of title or content?
  • Should nested replies be creatable from v1 UI, or only through the data/API model for now?
  • Should strict POST idempotency be added now? The standards-preferred answer is yes, but it adds a replay table. The 80/20 answer is to rely on pending-state UI and accept low-consequence duplicate risk for v1.
  • Should server slug generation replace client slug generation for all content creates in the same pass, or only for tweet/reply creation?
  • Existing comments in routes.blackbox.test.ts use comments to describe migration behavior. New tests should avoid unnecessary comments unless a route migration nuance is not obvious.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions