Skip to content

Build-a-box

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/build-a-box.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What build-a-box is for

A build-a-box subscription's persisted composition must always sum to exactly the plan's box_size — never partial, never over. validateComposition (extensions/build-a-box.ts:23-42) is the only path that writes current_composition, on both onSubscriptionCreate and the portal PUT, and rejects any total ≠ box_size or any ineligible variant before persistence. If this invariant breaks — a composition saved with the wrong total, or an ineligible item slipping through — a downstream consumer that trusts the stored composition (the line-item materialization ADR-0033 promised, not yet built; see Move 3) would ship the wrong contents or the wrong quantity. And today's onScheduledRenewal veto only checks length === 0, not the sum — it would not catch a malformed-but-non-empty composition. (Source: extensions/build-a-box.ts:23-42, consumed by routes/portal/box-composition.ts:252 and extensions/build-a-box.ts:129; proven by gtm-build-a-box.scenario.ts's box-rejects-invalid-via-route step and extensions/__tests__/build-a-box.test.ts.)

The domain breaks into five features, each anchored to its build story (US-6.3) for traceability:

  • Build your box — a Subscriber to a build-a-box plan picks exactly box_size items from the merchant's curated eligible-product list, so they customize what ships without cancelling and resubscribing to a different plan (US-6.3)
  • Customize within a window, then it locks — the subscriber can change their composition until the merchant-configured customization window closes for the cycle; after that, the selection is locked for shipment (US-6.3)
  • Unchanged box rolls forward every cycle — if the subscriber doesn't touch their composition, the previous cycle's selection ships again automatically; no re-pick required each period (US-6.3)
  • Merchant configures the box — the merchant sets box_size, the eligible-product list, and the customization-window length per plan from the admin (US-6.3, box-config route)
  • Portal picker shows real product data — the eligible id/SKU list resolves to name, image, and price via BC's catalog so the subscriber sees a real "Build your box" grid, not raw SKUs (US-6.3 storefront-render follow-on, #1784)

The decisions that carry the most weight

1. Shared subscription_extensions substrate, not a bespoke box-composition table. Build-a-box is one of five polymorphic extension types riding the same JSONB substrate + per-type service-layer convention as gift, prepaid, allotment-grant, and custom-fields (ADR-0033).

2. Uniform ExtensionService lifecycle-hook interface. Build-a-box implements onSubscriptionCreate, onScheduledRenewal (renewal veto with mode: 'hold' when composition is empty inside an open window), and onChargeSuccess (deadline reset) against the same typed interface every extension implements, dispatched generically by extensions/lifecycle-hooks.ts rather than bespoke per-type scheduler branches (ADR-0051).

3. validateComposition is the single sum-to-box_size gate. Every write path — initial creation and the portal update — routes through one pure validator that rejects any composition whose quantities don't sum to exactly box_size or that references an ineligible variant. There is no second, looser write path (extensions/build-a-box.ts:23-42, consumed identically by routes/portal/box-composition.ts).

4. Generic lifecycle wrappers can silently have zero call sites. fireOnSubscriptionCreate and fireOnChargeSuccess are dispatcher functions that iterate the extension registry, but nothing guarantees a production code path actually calls them — proven true for both (Hive #1783) before fireOnChargeSuccess was wired into the scheduler's success path. fireOnSubscriptionCreate still has zero production call sites as of this trace (Hive #1825, OPEN).

How it actually works

Read the deciding decisions before the code, not after. ADR-0033 establishes subscription_extensions as the substrate and explicitly names build-a-box's onChargeSuccess hook as doing "line-item materialization reads current_composition." That design was never built — materializeBcOrderForCharge never reads a composition. If you read the ADR's design table and assume it describes running code, you will derive the wrong mechanism. See Move 3 for the delta.

Merchant configuration. handleAdminPlanBoxConfigGet / ...Update (routes/admin/plans/box-config.ts) read and write three columns directly on plans: box_size, box_eligible_products (a JSON array of variant/SKU ids), and box_customization_window_days. Setting box_size on a plan is what turns it into a build-a-box plan — there is no separate is_build_a_box flag. The update handler validates each field independently (positive integer or null) and applies only the fields present in the request body.

Subscriber composition — read. handlePortalBoxCompositionGet (routes/portal/box-composition.ts:169-213) verifies the portal-session JWT in-handler, then resolves the subscription via ownedSubscription — a SELECT bound to the verified, string customer_id from the session payload, never a caller-supplied id (anti-IDOR; cross-customer access returns 404). It reads the plan's box config, the subscriber's build_a_box extension row if one exists, and calls resolveEligibleProducts to enrich the raw eligible-id list with name/image/price from BC's catalog (/v3/catalog/products?sku:in=...). That enrichment degrades to the raw id as the display name on any failure — a missing OAuth token, an upstream error, an unresolved id — and never throws, so a catalog outage can't break the authoritative composition read.

Subscriber composition — write. handlePortalBoxCompositionUpdate (routes/portal/box-composition.ts:215-275) re-resolves ownership and plan the same way, then checks the window: if an extension row exists and next_customization_deadline has passed, it returns 409 (window_closed) before touching the body. Otherwise it runs the submitted items through validateComposition and, on success, persists via an INSERT ... ON CONFLICT (subscription_id, extension_type) DO UPDATE into subscription_extensions with extension_type='build_a_box'. The existing deadline carries forward on update; a first-time write stamps a fresh deadline windowDays out from now.

Creation. buildABoxService.onSubscriptionCreate (extensions/build-a-box.ts:110-145) is dispatched by fireOnSubscriptionCreate when a subscription is created with a build_a_box extension payload. It reads the plan's box_size — skipping silently if the plan has none, i.e. it isn't a box plan — validates the submitted composition against the same validateComposition gate, and writes the extension row with a fresh next_customization_deadline.

Renewal veto. onScheduledRenewal (extensions/build-a-box.ts:161-175) blocks the renewal (proceed: false, mode: 'hold', the default per lifecycle-hooks.ts:38) only when the customization window is still open and current_composition is empty — a subscriber who hasn't picked anything yet doesn't get charged for an empty box. Once the window closes, renewal proceeds regardless of composition state; there is no sum-check at this gate (see the invariant in Move 1).

Renewal-time deadline reset. onChargeSuccess (extensions/build-a-box.ts:147-159) resets next_customization_deadline forward by customization_window_days and leaves current_composition untouched — the unchanged box rolling forward automatically. This hook is dispatched by fireOnChargeSuccess (extensions/lifecycle-hooks.ts:45-56), called unconditionally after every successful charge from scheduler.ts:1769. git log -S 'fireOnChargeSuccess' -- apps/api/src/scheduler.ts shows this wiring landed in commit 96639316 ("wire fireOnChargeSuccess in scheduler success path (Refs #1783)"), and buildABoxService self-registers via extensionRegistry.register at build-a-box.ts:178 — a module-level side effect that fires in production because worker.ts transitively imports box-composition.ts, which imports build-a-box.ts.

erDiagram
    subscription_extensions {
        TEXT subscription_id PK
        TEXT store_hash
        TEXT extension_type PK
        TEXT extension_data
        INTEGER extension_version
        TIMESTAMP created_at
        TIMESTAMP updated_at
    }
    plans {
        INTEGER box_size
        TEXT box_eligible_products
        INTEGER box_customization_window_days
    }
    subscriptions ||--o{ subscription_extensions : "subscription_id"

Diagram provenance. Excerpt of the canonical, code-sourced docs/architecture/data-model-erd.md § plans and § subscription_extensions — only the three build-a-box columns on plans are shown (the table has ~40 more columns for pricing, shipping, and other extension types), plus the shared extension table this domain's composition rows live in. This source carries no sign_off field; its own staleness marker is as_of_commit: 80fc35f4, staleness_threshold_days: infinite. Transcluded verbatim, not hand-drawn.

No process-flow diagram exists for build-a-box. Unlike gift-subscriptions' process-flows.md §5, the composition-selection → lock → renewal-veto → roll-forward sequence is undiagrammed as of this trace — a documentation gap in its own right, not an omission by this page.

Where intent and reality diverge

The derived coverage matrix (_coverage-matrix.json) reports US-6.3 at terminal_gate: "G4", g4_status: "pass", dod_bucket: "tested". That is true, and — as with every domain in this corpus — it is not the whole truth. Five honest deltas, each typed:

1. Superseded-framing residue — the BRD's own data-contract prose is wrong. BRD.md §US-6.3 (surfaced in docs/audits/derived/brd-epics/epic-06-advanced-subscription-types.md line 264) still describes the mechanism as plans.box_size, plans.eligible_product_ids, plans.customization_window_days, and subscriptions.current_box_composition (JSONB) — a pre-ADR-0033 data-model draft. The shipped schema uses plans.box_eligible_products / plans.box_customization_window_days (schema.sql:226) and stores composition in the polymorphic subscription_extensions.extension_data, not a dedicated subscriptions column. The BRD prose was never corrected after the substrate ADR superseded it — a reader following the BRD Data Contract prose literally would query columns that do not exist.

2. Superseded-framing residue — a storefront comment described a stale mechanism, and that comment has since been corrected. BuildABoxPanel.svelte's header comment (introduced in commit c56df856, 2026-06-27 10:38) originally stated "the window does NOT reopen per renewal (build_a_box.onChargeSuccess is unwired)." Commit 96639316, six hours later the same day, wired fireOnChargeSuccess into the scheduler's success path (closing Hive #1783); the comment went uncorrected until commit f8dfdc07 (2026-07-02), which rewrote it to state the window DOES reopen each cycle. As of as_of_commit: cb211cb0 above, the comment and the code agree — see Confidence notes for the timing detail this leaves open.

3. Verified-but-incomplete — composition capture is built; the affordance it was designed to feed is not. Composition capture, validation, persistence, and the renewal-veto gate are built and G4-proven; the affordance ADR-0033 explicitly designed on top of them — onChargeSuccess doing "line-item materialization reads current_composition" — was never wired. materializeBcOrderForCharge (orders.ts:380-382) unconditionally builds every order's products array starting with only the plan's base bc_product_id; no code path in apps/api/src reads a subscription's current_composition when constructing BC order line items. A build-a-box subscriber's chosen items are captured and gate whether a renewal proceeds, but never affect what BC actually orders or ships.

4. Named-deferred — "build your box before subscribing" on the PDP is not built. The storefront order-webhook create path (webhooks.ts:createSubscriptionsFromOrder) never calls fireOnSubscriptionCreate because SubscriptionIntent (the storefront contract) has no extensions field to carry an initial composition. Filed and OPEN as Hive #1825, which names the required storefront-contract, widget, and webhook-handler changes. The honest current entry is "subscribe first (base plan only), then build your box in the portal."

5. Contract-verified, not live-verified — the domain is G4-tier, with an uneven proof depth across its own routes. The portal box-composition read/write routes are proven through real HTTP dispatch — gtm-build-a-box.scenario.ts drives worker.fetch with a minted portal JWT against real D1 (applySchema), and its box-rejects-invalid-via-route step is what proves the invariant in Move 1 at the HTTP boundary. The admin box-config route is proven only by a 401-not-404 smoke (route-wiring.scenario.ts) — no authenticated admin-flow scenario exists, and the GTM scenario seeds plan box config directly into D1 rather than exercising the admin route. No scenario asserts the onChargeSuccess deadline reset fires across a real multi-cycle scheduler run — unlike the equivalent for allotment_grant, asserted directly in p2-alex-digital-content.scenario.ts. No G5 (live-sandbox) run exists for any part of this domain.

How to operate & extend

  • Turn a plan into a build-a-box plan: PUT /api/v1/admin/plans/:id/box-config with box_size, eligible_variant_ids, and customization_window_days (routes/admin/plans/box-config.ts). Setting box_size is what makes the plan a box plan — every read path checks plan.box_size truthiness, not a separate flag.
  • The invariant you must not break: every write to current_composition — initial creation and portal update — must go through validateComposition (extensions/build-a-box.ts:23-42). Do not add a second write path; the renewal veto only catches an empty composition, not a malformed one, so a validator bypass is the one way this domain silently ships the wrong box.
  • Composition rejected unexpectedly? validateComposition throws distinct messages for a wrong total ("Box total must be exactly N items, got M"), an ineligible variant, and a non-positive quantity — the portal route maps a non-null return to a 422 via invalidFields.
  • Window closed but the subscriber wants to edit? PUT /box-composition returns 409 (window_closed) once next_customization_deadline has passed; there is no override path in this domain — the merchant-configured customization_window_days reopens automatically at the next onChargeSuccess.
  • New extension seam: the build_a_box extension type on subscription_extensions, dispatched through the uniform ExtensionService interface, is the pattern to follow for a new polymorphic subscription behavior — see ADR-0033 for the substrate convention and ADR-0051 for the hook contract. Before adding a new hook call site, check whether the dispatcher already has zero call sites in production — fireOnSubscriptionCreate currently does (Move 1, decision 4).

Confidence notes

  • The BuildABoxPanel.svelte comment correction (delta #2) landed the same day as this trace, and possibly from this trace. Commit f8dfdc07 (2026-07-02) rewrote the stale comment and explicitly credits "a handoff-corpus trace" as the source. This page's Input-B was ratified the same day. I could not establish from the repo alone whether the Input-B attestation or the code fix came first — the practical effect is the same (code and comment now agree), but treat the "comment was never updated" framing as historically true, not necessarily current at the moment you read this.
  • No G5 (live-sandbox) proof exists for this domain. A grep for build-a-box-specific e2e/Playwright specs (apps/storefront-svelte/e2e, e2e/journey, e2e/consumer-flows) returned zero domain-specific matches, matching Input-B's claim. Composition capture, validation, and the renewal veto are G4-proven only.
  • The admin box-config route's authenticated behavior is unproven beyond a 401 smoke. I read route-wiring.scenario.ts:93-98 directly and confirmed it asserts 401-not-404 for all four routes in this domain, not a full authenticated read/write cycle for the admin route specifically.
  • fireOnSubscriptionCreate zero-call-site claim: I did not independently re-grep every call site across apps/api/src beyond confirming webhooks.ts:createSubscriptionsFromOrder doesn't call it; Input-B's live-state attestation cites its own exhaustive grep for this (Hive #1825 as the tracking issue).