Magpul Mode Label Constraint - Plan#
Goal Capsule#
- Objective: Add an opt-in, per-account Magpul mode that constrains a magazine's
labelto the PMAG Gen M3 dot-matrix character set when enabled, and leaves labels as free text when disabled. Enforcement is a UX/domain-layer function, not a database constraint. - Product authority: Decisions below were confirmed with the requester during brainstorm and hardened by a
ce-doc-reviewpass; treat them as pinned unless planning surfaces a conflict. - Open blockers: None. The two prior open items are resolved: the flag is stored as a Better Auth
additionalFieldsboolean onuser(KTD-1), and the owner's mode governs (Key Decision, R3). - Product Contract preservation: Product Contract changed during the review pass (R2/R3/R6/R7/R9/R10/R11 clarified; owner's-mode and grandfather-on-edit decisions added; AE7–AE10 added) — all user-approved via
ce-doc-review. Planning below adds only the HOW; it does not alter product scope.
Product Contract#
Summary#
Introduce a per-account Magpul mode profile toggle (default off). When on, a magazine label must fit what a Magpul PMAG Gen M3 floorplate can physically hold — up to 4 characters from A-Z, 0-9, and - — enforced through a form input mask plus domain validation. When off, labels stay unrestricted free text with no rendering. This issue owns the shared mode flag and character-set/length constants that #20 (dot-matrix rendering) and #22 (label prefixes / auto-numbering) build on.
Problem Frame#
magazine.label is free text today. Issue #20 renders the label as a Magpul paint-pen dot matrix, whose glyph font only covers A-Z, 0-9, and - on a fixed grid — so a rendered label can only represent that set, and only a few characters fit the floorplate. The original issue #21 proposed constraining every label to that set with a database check backstop.
That over-generalizes. Not every owner marks magazines with a PMAG M3 dot matrix; some stencil or spray-paint arbitrary identifiers and have no reason to accept a 4-character alphanumeric limit. A global restriction (and a table-level DB constraint) would wrongly punish those owners. The constraint is only meaningful for owners who opt into the Magpul dot-matrix workflow, so it belongs behind a per-account switch, enforced where the user actually types — not at the storage layer.
Key Decisions#
- Opt-in per account, not global. The dot-matrix constraint (and, in #20, the rendering) applies only when Magpul mode is on. Owners who paint arbitrary labels are unaffected.
- Owner's mode governs. Label validation keys on the magazine owner's Magpul mode, not the editor's — the label describes the owner's physical magazine. Under grant-based sharing, a grantee editing an owner's mode-on magazine sees the constraint (with copy explaining it, per R9) even if the grantee's own mode is off; a grantee's own mode never relaxes an owner's constraint.
- UX/domain enforcement, not the database. Enforcement lives in the domain layer (conditioned on the owner's mode) and the form. A table-level
CHECKgenuinely can't express this — it can't consult the owner's per-account flag. ABEFORE INSERT/UPDATEtrigger technically could, by joining the user record, but we deliberately reject that for the cross-table coupling and operational complexity it adds: this is a UX/validation feature, not a storage invariant. No DB backstop mirrors the rule, dropping #21's original DBcheck. - Layered enforcement. A form input mask is the first line (prevents most invalid input from being submitted); domain validation is authoritative (catches paste, API, and programmatic paths); there is no third database layer.
- Reject, don't auto-clean. After normalization, invalid input is rejected with a clear message rather than silently stripped or truncated, so a saved label always matches what the user meant.
- Grandfather existing labels on edit. When mode is on and the form opens a magazine whose stored label doesn't conform, the field shows the stored value verbatim; the mask and the rejection error do not fire until the user edits the field, and the record can be saved unchanged. The constraint governs new label input, not pre-existing values (see R11, AE7).
- Max length is 4, fixed by the hardware. The PMAG Gen M3 floorplate carries 4 dot cells (4 sets of a 3×5 grid), so
MAX_LABEL_LENGTH = 4— per Magpul's published PMAG Gen M3 dot-matrix diagram, with the hyphen occupying one cell like any glyph. Exact glyph geometry is #20's concern. - No retroactive rewrite; truncate the render, not the record. Enabling Magpul mode never touches existing labels and never forces correction of nonconforming ones. The stored value is preserved verbatim. Display handling of a nonconforming stored label — truncating the rendered dot matrix to what fits — is #20's responsibility; this plan records it as an input constraint for #20, not a contract owned here.
- Alternatives considered. A per-magazine "marking type" flag (constraint as a property of each magazine) and an always-render-with-warnings approach (no hard limit) were weighed. Rejected in favor of a per-account opt-in: an owner either works the PMAG dot-matrix way or doesn't, and opt-in signals intent for hard enforcement. Revisit if a second per-account setting appears or if mixed per-magazine marking becomes a real need.
Requirements#
Profile setting
- R1. A per-account Magpul mode boolean defaults to off. It is conceptually a profile setting; its physical storage is a planning decision (see Outstanding Questions).
- R2. A user can toggle their own Magpul mode, and it governs validation and masking for the magazines they own.
Label constraint when Magpul mode is on
- R3. When the magazine owner's Magpul mode is on, a
labelis valid only if, after normalization, it contains solelyA-Z,0-9, and-and is at mostMAX_LABEL_LENGTH(4) characters. An empty label stays valid. - R4. Normalization uppercases the input and trims outer whitespace before validation.
- R5. Input still invalid after normalization — unsupported characters, internal spaces, or more than 4 characters — is rejected with a clear, user-facing message naming the allowed set and the max length. It is never silently stripped or truncated.
- R6. The allowed character set and the max length are declared once as shared named constants. This validator and the magazine form consume them; #20's renderer and #22's numbering will import the same constants when built, so all surfaces stay in agreement. No export surface is designed now for those unbuilt consumers — they import what exists.
Enforcement surfaces
- R7. When the magazine owner's Magpul mode is on, the form applies a live input mask: it uppercases as the user types, filters keystrokes to the allowed set, and caps length at 4 — so most invalid input never reaches submit. The mask is an affordance; R5 domain validation remains authoritative for paste and non-form paths.
- R8. Enforcement is UX/domain-layer only. No database
checkconstraint mirrors this rule. - R9. The form surfaces the rule accessibly: helper text naming the allowed set and limit is visible the whole time the constraint is active (not only on error); on rejection, the input is marked invalid and its error message is programmatically associated with the input and announced; characters the mask silently drops are announced via a live region so non-visual users learn the constraint. When a grantee edits an owner's mode-on magazine, the helper copy explains the constraint comes from the owner's setting. Targeted via ARIA roles and accessible names, no
data-testid.
Behavior when Magpul mode is off
- R10. When the magazine owner's Magpul mode is off,
labelis unrestricted free text (current behavior): no charset restriction, no length cap, no input mask, no dot-matrix rendering. - R11. Enabling Magpul mode does not retroactively rewrite existing labels and does not force correction of nonconforming ones; the stored value is preserved as-is. When mode is on and the edit form opens a magazine with a nonconforming stored label, the field shows that value verbatim and the mask/error stay dormant until the user edits the field, so the record can be saved unchanged (see AE7). Truncating the rendered dot matrix to what the matrix can represent is #20's responsibility — recorded here as an input constraint for #20, not a contract owned by this plan.
Acceptance Examples#
- AE1. Covers R3, R4, R7. Given Magpul mode on, when the user types
ar-1, the form displaysAR-1and savesAR-1. - AE2. Covers R5. Given Magpul mode on, when a 6-character value like
AR-15Xreaches the domain layer (e.g., via paste past the mask or an API call), it is rejected with a message naming the 4-character limit. - AE3. Covers R5. Given Magpul mode on, when
A.1is submitted (a.survives uppercase/trim), it is rejected with a message naming the allowed setA-Z,0-9,-. - AE4. Covers R3, R10. An empty label is accepted whether Magpul mode is on or off.
- AE5. Covers R10. Given Magpul mode off, a label like
My Rifle #1is saved unchanged. - AE6. Covers R11. Given a magazine labeled
range gunand the owner then enables Magpul mode, the stored label staysrange gun; the detail view renders only the portion the matrix can represent (up to 4 supported glyphs), and nothing forces the owner to change the record. - AE7. Covers R11. Given the owner's mode is on and a magazine's stored label is
range gun, when the owner opens the edit form, the label field showsrange gunverbatim with no error; the record can be saved unchanged. Only once the owner edits the label field do the mask and the R5 rejection apply. - AE8. Covers R1. Given a new account (mode defaults off), when the owner adds or edits a magazine, the label field applies no mask and accepts free text.
- AE9. Covers R2, R3, R7. Given an owner who turns their Magpul mode on, the next label edit on a magazine they own is masked and validated against the allowed set and 4-character limit.
- AE10. Covers R3 (owner's mode governs). Given an owner with mode on and a grantee whose own mode is off, when the grantee edits the owner's magazine, the label is still constrained to the allowed set and limit, and the form explains the constraint comes from the owner's setting.
Scope Boundaries#
- The dot-matrix SVG rendering and its glyph font (#20). This plan defines the shared mode flag and the character-set/length constants #20 reads, but not the renderer itself. The record-preservation guarantee (R11) is surfaced as an input constraint for #20, not a contract owned or gated here — see Dependencies.
- Label prefixes and auto-numbering (#22).
- Floorplate variants other than PMAG Gen M3, and per-glyph grid geometry.
- Any non-ASCII or localized extension of the allowed character set.
Dependencies / Assumptions#
- No profile-settings surface exists today. The
usertable (src/db/auth-schema.ts) is Better Auth-managed and there is no user-preferences store. Magpul mode is the app's first per-account profile option; its storage shape is a planning decision (see Outstanding Questions). MAX_LABEL_LENGTH = 4derives from the PMAG Gen M3 floorplate (4 dot cells). The value and the allowed set are shared constants with #20 and #22.- Existing validation pattern.
src/domain/magazines/validate.tsreturns all failure codes together in a single pass (the aggregate-failure-codes convention already used there); the label rule is expected to extend that surface, conditioned on the governing mode. - Cross-issue dependency (→ #20). #20's renderer must honor R11: never rewrite the stored label, and truncate the rendered dot matrix to the 4-cell capacity for nonconforming stored values. This plan states the constraint; #20 owns implementing and gating it.
Outstanding Questions#
Deferred to planning
- Exact wording of the helper text and the rejection error message. (Storage location is resolved by KTD-1: a Better Auth
additionalFieldsboolean onuser.)
Deferred to follow-up work
- When a user enables Magpul mode, proactively surfacing a count or list of their existing nonconforming labels (a discovery aid so they can update them). Out of scope here; R11 already guarantees nothing breaks without it.
Planning Contract#
Key Technical Decisions#
- KTD-1. Store
magpulModeas a Better AuthadditionalFieldsboolean onuser(defaultfalse), not a separate settings table. The admin plugin already added custom columns (role,banned, …) to the generatedusertable, so the pattern is proven; the flag then rides the session for the common self-edit path with no extra query. A dedicateduser_settingstable would add a join and a table for one bit.auth-schema.tsis CLI-generated — the column is added by declaring the field inauth.tsand re-runningbun x @better-auth/cli generate, thenbun run db:generate+db:migrate. Revisit the table if a second per-account setting appears. - KTD-2. Owner's mode is resolved server-side and passed into the pure validator.
validateMagazinestays pure: it receives anownerMagpulModeboolean (and the candidate label) as context, never fetches. The service resolves the owner (already does, viaresolveCreateOwner/authorizeUpdate) and reads that owner'smagpulModeinside the same transaction before validating — so create-on-behalf keys on the owner's flag, not the actor's (AE10). Unit tests pass the boolean directly; integration tests exercise the real lookup. - KTD-3. Grandfather is enforced by change-detection, not a mode carve-out. On update, the label rule fires only when the submitted label differs from the stored value; an unchanged (possibly nonconforming) label saves untouched (R11, AE7). On create, any nonempty label is new and validates. This is the single rule behind both the domain behavior and the form's "don't mask/error until edited" affordance.
- KTD-4. Normalization lives in the service's
scalarFields, not the validator. The validator uppercases+trims only to run its checks (keeping it pure/read-only, matching the existingbrandModel.trim()convention). The stored value is normalized (uppercase + outer-trim) inscalarFieldswhen the owner's mode is on and the label is being set; when mode is off the raw value is stored. Nothing is stripped or truncated — invalid input is rejected upstream (R5). - KTD-5. New failure codes extend the existing aggregate-codes array in parity order. Add
invalidMagpulLabelandmagpulLabelTooLongtoMagazineValidationCodeand toVALIDATION_MESSAGES, placed in the intentional code order the multi-failure test pins. Bulk add resolves the owner's mode too: when on, it normalizes the prefix and rejects the whole batch (atomically) if any generated label breaks the charset or 4-char cap, reusing these codes; when off, behavior is unchanged. Prefix auto-numbering design (width strategy, allocation) remains #22's concern — this plan only enforces the constraint on whatever labels bulk add generates. - KTD-6. The form mask keys on the governing mode for self-owned magazines (the common case) and defers to domain validation otherwise. The mask is an affordance (R7); when the owning user's flag isn't readily available client-side (a shared magazine), the domain layer remains authoritative and rejects. This avoids threading per-magazine owner lookups through the list RSC for an affordance.
Assumptions#
- The magazines list/form is primarily used by owners on their own magazines; the mask affordance targets that path, with domain validation covering the rest.
bun x @better-auth/cli generateregeneratesauth-schema.tsdeterministically fromauth.ts; the generated column name ismagpul_mode(snake_case) mapping tomagpulMode.- Integration tests may follow the existing
DATABASE_URL-gateddescribe/describe.skippattern; new backing-service tests use Testcontainers perAGENTS.md.
Sequencing#
U1 → U2 → U3 → U4, with U5 and U6 depending on U1 (and U5 on U3/U4 for the error surface). U2 is a prerequisite for U3.
Implementation Units#
U1. Add magpulMode to the user profile + migration#
- Goal: Persist a per-account
magpulModeboolean (default off) and expose it on the session user. - Requirements: R1; enables R2, R3.
- Dependencies: none.
- Files:
auth.ts(declareuser.additionalFields.magpulMode),src/db/auth-schema.ts(regenerated — addsmagpul_mode),src/db/migrations/(new generated.sql+meta/_journal.json),src/auth/session.ts(extendSessionUserwithmagpulMode),src/auth/__tests__/orsrc/domain/settings/__tests__/(integration). - Approach: Add
additionalFields: { magpulMode: { type: "boolean", defaultValue: false, input: false } }to theuserconfig inauth.ts; regenerateauth-schema.tsvia the Better Auth CLI;bun run db:generateto emit the migration; extendSessionUserandgetCurrentUser()to carrymagpulMode. - Patterns to follow: the admin-plugin columns already on
user; the CLI-regen note insrc/db/schema.ts; the lazy pool insrc/db/client.ts. - Test scenarios:
- Integration (DATABASE_URL/Testcontainers): applying migrations creates
user.magpul_modewith defaultfalse. getCurrentUser()for a freshly seeded user returnsmagpulMode: false. Covers AE8.
- Integration (DATABASE_URL/Testcontainers): applying migrations creates
- Verification:
bun run db:migrateapplies cleanly; typecheck passes with the extendedSessionUser.
U2. Shared label constants module#
- Goal: One source of truth for the allowed set and max length.
- Requirements: R6.
- Dependencies: none.
- Files:
src/domain/magazines/constants.ts. - Approach: Export
MAX_LABEL_LENGTH = 4and the allowed-charset matcher (e.g. anA–Z 0–9 -pattern). The validator and the form import these; #20/#22 import them when built. No export surface designed for the unbuilt consumers. - Test scenarios:
Test expectation: none — pure constants, exercised via U3. - Verification: imported by U3 without duplication.
U3. Extend domain validation for the label constraint#
- Goal: Reject nonconforming labels when the owner's mode is on; keep the validator pure.
- Requirements: R3, R4, R5, R6.
- Dependencies: U2.
- Files:
src/domain/magazines/validate.ts,src/domain/validation-messages.ts,src/domain/magazines/__tests__/validate.test.ts. - Approach: Add an optional context param carrying
{ label?, ownerMagpulMode?, previousLabel? }. WhenownerMagpulModeis true and the label is being set/changed (KTD-3), uppercase+trim the candidate and pushinvalidMagpulLabelfor out-of-set characters andmagpulLabelTooLongbeyondMAX_LABEL_LENGTH; empty stays valid. Codes join the existing array in parity order (KTD-5). - Execution note: Implement the new codes test-first against the parity ordering example.
- Patterns to follow: the existing multi-code accumulation and the
firstMessage/messageForCodemapping. - Test scenarios: (table-driven)
- Covers AE1/AE9.
ar-1with mode on → no code (normalizes toAR-1). a1with mode on → no code (uppercased). Empty with mode on → no code (AE4).- Covers AE3.
A.1with mode on →invalidMagpulLabel. - Internal space
A Bwith mode on →invalidMagpulLabel. - Covers AE2.
AR-15(5 chars) with mode on →magpulLabelTooLong. - Mode off → any label returns no label code (AE5).
- Multi-failure ordering test still passes with the new codes inserted.
- Covers AE1/AE9.
- Verification:
bun testgreen; new codes appear in the pinned order.
U4. Wire owner's-mode resolution + normalization into the service#
- Goal: Resolve the owner's mode in-transaction, validate against it, and normalize the stored label.
- Requirements: R3, R5, R10, R11; AE6, AE7, AE10.
- Dependencies: U1, U3.
- Files:
src/domain/magazines/service.ts,src/domain/magazines/__tests__/service.test.ts. - Approach: In
createMagazine/updateMagazine, after resolving the owner, read that owner'smagpulModewithin the transaction and pass it (with the candidate label and, for update, the storedpreviousLabel) intovalidateMagazine. InscalarFields, when the owner's mode is on and the label is being set, store the normalized (uppercase + outer-trim) value; otherwise store raw (KTD-4). Update validates the label only when it differs from the stored value (KTD-3). Bulk-add passes no single-label context (unchanged). - Patterns to follow:
resolveCreateOwner/authorizeUpdate; the privatescalarFieldshelper; themakeMagazinefactory for seeding. - Test scenarios: (integration, DATABASE_URL/Testcontainers)
- Owner mode on: create with
AR-15→ValidationError(magpulLabelTooLong); create withar-1→ storedAR-1. - Owner mode off: create with
My Rifle #1→ stored verbatim. Covers AE5. - Covers AE10. Actor is a create-on-behalf grantee with mode off, owner mode on → label still constrained.
- Covers AE7. Update a magazine whose stored label is
range gun(owner mode on) without changing the label → succeeds unchanged; changing it toA.1→ rejected. - Update that changes only caliber leaves a nonconforming stored label intact (AE6/R11).
- Owner mode on: create with
- Verification:
bun testintegration suite green against a live/Testcontainers DB.
U5. Magazine form: input mask + accessible surfacing#
- Goal: Apply the live mask and accessible helper/error when the governing mode is on, honoring grandfather.
- Requirements: R7, R9; AE1, AE7, AE9.
- Dependencies: U1, U3, U4.
- Files:
app/(app)/magazines/magazine-form.tsx,app/(app)/magazines/magazines-view.tsx,app/(app)/magazines/page.tsx,app/(app)/magazines/actions.ts,e2e/(Playwright spec). - Approach: Thread the governing
magpulModefrom the page RSC (self-owned → current user's session flag) down to the form. In the labelonChange, when mode is on and the field has been interacted with, uppercase → filter to the allowed set → cap at 4 (plusmaxLength). Show helper text via the existingFieldhintwhenever mode is on (persistent). MapinvalidMagpulLabel/magpulLabelTooLongto inline errors via the existingfirstMessage/codes+role="alert"path; associate the message with the input and announce mask-dropped keystrokes via anaria-liveregion. For a nonconforming initial value, leave the field verbatim and dormant until first edit (KTD-3, R11). - Patterns to follow: the controlled-state
set()helper andField/Inputcomponents;hintusage infirearm-form.tsx;firstMessage(codes, [...])binding. - Test scenarios: (Playwright e2e, Docker; target by role/label/text, no
data-testid)- Covers AE1/AE9. Mode on: typing
ar 15!yieldsAR15; save persistsAR15(mask filtered space+!, capped 4 → wait:ar 15!→AR15). - Helper text naming the allowed set + limit is visible whenever mode is on.
- Mode off:
My Rifle #1accepted, no mask. Covers AE5. - Covers AE7. Opening an existing
range gunmagazine with mode on showsrange gunand no error until the field is edited.
- Covers AE1/AE9. Mode on: typing
- Verification:
bun run test:e2epasses for the magazine form flows.
U6. Settings page with the Magpul mode toggle#
- Goal: A minimal profile settings surface where the user toggles their own Magpul mode.
- Requirements: R1, R2; AE8, AE9.
- Dependencies: U1.
- Files:
app/(app)/settings/page.tsx,app/(app)/settings/settings-form.tsx,app/(app)/settings/actions.ts,app/(app)/app-shell.tsx(nav entry),e2e/(Playwright spec). - Approach: A single-purpose settings page (not a general framework — scope discipline) rendering the current user's
magpulModeas a toggle. The server action updates the flag via Better Auth'supdateUser(additionalFields are updatable) or a scopeddbupdate on the acting user, then revalidates. Add aSettingsnav entry inapp-shell.tsx. - Patterns to follow: existing
(app)route + server-action shape;getCurrentUser();app-shell.tsxnav list. - Test scenarios:
- Integration/server-action: toggling persists
magpulModefor the acting user only. - Covers AE9 (e2e): after enabling the toggle, the next magazine label edit is masked/validated.
- Covers AE8 (e2e): a default-off account applies no mask.
- Integration/server-action: toggling persists
- Verification: toggle persists across reload; nav entry reachable;
bun run test:e2egreen.
Verification Contract#
| Gate | Command | Applies to |
|---|---|---|
| Lint/format | bun run lint | all units |
| Types | bun run typecheck | all units |
| Unit + integration | bun test | U1, U3, U4, U6 (integration gates on DATABASE_URL) |
| Migration applies | bun run db:migrate | U1 |
| E2E | bun run test:e2e (Docker) | U5, U6 |
Definition of Done#
magpulModepersists per account (default off), toggled from a settings page, surfaced on the session (R1, R2).- With the owner's mode on, labels are constrained to
A-Z/0-9/-and ≤4 chars, normalized (uppercase + outer-trim), and invalid input is rejected with a clear message — enforced in the domain layer and the form mask, with no DB constraint (R3–R9). - Create-on-behalf keys on the owner's mode (AE10); mode-off is unrestricted free text (R10, AE5).
- Existing nonconforming labels are preserved and never force-corrected; unchanged saves succeed (R11, AE6, AE7).
- Shared constants back both the validator and the form (R6);
#20/#22can import them. - All acceptance examples (AE1–AE10) are covered by tests;
bun run lint,bun run typecheck,bun test, andbun run test:e2eare green; the migration applies cleanly.