Kestrel Fastening Systems KESTREL FASTENING SYSTEMS

Branding path

The repeatable order in which an agent turns this baseline into a specific customer's B2B shop, using only skills and content changes. Each step names its skill, its tools, the input it needs from customer-context/, and the assert that proves it.

The two standing rules

RULE 1 — branding never edits a layer.
F:\code\Truvio.Commerce.Distribution is the source of the baseline, not a working tree. A
layer is a versioned unit that a gate proved. Editing layers\* to brand a demo means the next
`git pull --ff-only` either clobbers your work or refuses to fast-forward, and the edition you
claim to have composed is no longer the edition you ran. Brand through MCP writes against the
running instance and through files under `Files/`. If something genuinely belongs in the
Distribution, that is a Foundry PR (dw-demo-foldback), not an in-place edit.
The concrete forms of this rule as the skills state them:
  "Customer overrides go in `<customer>_custom.css`, never by editing `theme-default`'s own
   files."                       — dw-demo-swift/references/re-skin.md:46
  "editing `theme-default`'s `default.*` Style assets in place … the re-skin hard rule forbids
   and … the next layer update reverts."
                                 — dw-demo-swift/references/styles-assets.md:104-105
  "strip the offending column lines from the STAGED copy, never from the checked-out original
   under `distribution\layers\base\`."
                                 — dw-demo-swift/references/deserialize-flow.md:177
RULE 2 — customer-context is read-only.
`<demo>\customer-context\` holds what the customer handed over. Read it; never write it.
Transformed output goes to `<demo>\notes\` or `<demo>\extracts\`. A write to any path
containing `customer-context` is a hard abort with no approve branch. Detection signature at
end of phase: `git status --porcelain "customer-context/"` is empty.
Its sister is the customisations ledger: a write under Controllers/, Providers/, or any
*Controller.cs prompts Approve+log / Refactor instead / Cancel, and every approved row is a
deliberate trade-off you will show on the closing slide.
Note for the person filing issues from this plan. There is currently no guarded-write preflight on distribution\layers\ anywhere in the skill set.** The only enforced globs are the controller/provider set and customer-context\ (dw-demo-base/SKILL.md:149-163). Rule 1 above is assembled from three scattered statements in dw-demo-swift references; it is not stated as a contract anywhere the way Rule 2 is. That gap is Finding F-G1.

The overlay convention

customer-context/ is an OVERLAY on the baseline, not a replacement for it. The baseline
supplies structure and mechanism; customer-context supplies nouns — the company name, the
palette, the product families, the account roles, the currency, the price logic, the personas.
Branding is the act of walking the ordered steps below and, at each one, reading one specific
fact out of customer-context and writing it into the instance.
Derived artefacts live in notes/:
  notes/wording.md        the customer's own vocabulary, harvested from the intro-call notes
                          and applied across all demo copy ("speak the customer's words")
  notes/qa/               QA screenshots and visual-QA evidence
  notes/logs/             host logs
  notes/snapshots/        DOM / accessibility dumps
  notes/credentials.local.md   persona passwords (gitignored, never in the tracked ledger)
Root allowlist — only these may sit at the demo root: DEMO-PLAN.md, CLAUDE.md,
CUSTOMISATIONS.md, .gitignore, .mcp.json, and directories. Everything else routes to notes/,
with the same redirect wording as the customer-context contract.
Name evidence for what it IS (`home-desktop-*.jpeg`), never for what it was captured during.
Security-suggestive names for non-secret dumps are forbidden — they read as leaked-secret
files to any human or scanner.

Step 1 — Customer context

Skilldw-demo-base (dynamo: false) — references/customer-context.md, references/demo-tactics.md
Toolsnone (filesystem)
Inputthe whole customer-context/ folder
DoRead every artefact. Produce notes/wording.md: the customer's own words for their products, their customers, their roles, their processes. Decide the single deep storyline — one buyer persona, one CSR persona, one shop, one locale. Write DEMO-PLAN.md at the demo root.
Assertgit status --porcelain "customer-context/" is empty, and notes/wording.md exists carrying at least the company name, the product noun, and the two persona role names.

Demo philosophy that governs every later step: go deep, not wide. Personas floor of 2 (one buyer, one CSR, so impersonation has somewhere to land). Shops/channels 1 + 1. Locale: single home market. Customer-center sections: only the ones the storyline visits. The product catalogue is the deliberate exception — go deep AND wide there. Rich product data is cheap over MCP and makes the demo feel real; a product family does not have to justify itself against demo minutes, but every login, channel, locale and customer-center section does.

Step 2 — Theme overlay

Skilldw-swift-page-blocks (dynamo: true) "Color schemes & the design system"; dw-demo-swift (dynamo: false) references/styles-assets.md, references/re-skin.md
Toolsget_color_schemes -> save_color_schemes -> get_typographies -> save_typographies -> get_button_styles -> save_button_styles -> get_fonts
Inputthe customer's palette, logo and typeface from the alignment deck in customer-context/
DoRead first, then write. Style writes are not patch-safe: a color-scheme save fully overwrites the submitted scheme's colors (omitted colors nulled, custom colors cleared) while sibling schemes in the group survive; typography, button and font saves replace the WHOLE object.
Assertget_color_schemes returns your new values on the scheme ids the rows actually reference, and a rendered page shows the new palette. A CSS deploy asserts CSSOM rule presence, not byte presence — a rewriter cannot prove its own rule identity; re-parse with a parser that shares no code with it.

What this site actually ships, and it is not what dw-swift-page-blocks' illustrative list says:

Group id:      default        (NOT `swift`)
Scheme ids:    light        #FFFFFF / #1F2933
               lightgrey1   #F5F7FA / #1F2933
               lightgrey2   #E4E7EB / #1F2933
               dark         #1F2933 / #F5F7FA
               darksubtle   #323F4B / #CBD2D9
               primary      #1F2933 / #F5F7FA     (NOT #004fff)
               secondary    #3E4C59 / #F5F7FA
Typography id: default       Inter, base 16, scale 1.2, headings Inter 600 / -0.02 tracking
Button id:     default       Shape 2, BorderSize 1, PaddingY 0.55, PaddingX 1.4
Area binds:    AreaColorSchemeGroupId default, AreaColorSchemeId light,
               AreaTypographyId default, AreaButtonStyleId default

Tier 1 of the re-skin ladder is Files/Templates/Designs/Swift-v2/Custom/default_custom.css, loaded by DefaultHeadInclude.cshtml AFTER swift.css and the Style asset sheets. Its :root tokens (--td-ink, --td-accent, …) are what a customer re-skin overrides first. The hard rule: the customer's own sheet is <customer>_custom.css; git diff --name-only -- '*custom.css' must never show any other path ending in custom.css.

Do a Step-0 zero-state pass before styling: the frontpage title and meta title carry the customer's own words (save_pages with the real metaTitle), so nothing generic survives the first screenshot.

Step 3 — Areas and languages

Skilldw-swift-building (dynamo: true) SKILL.md:88-96 "Update the Area"; dw-content-localization (dynamo: true, no references/ — SKILL.md only)
Toolsget_areas -> get_area_by_id -> save_areas -> (host restart) -> find_unresolvable_item_pages -> create_language_version -> get_translatable_content -> apply_translation
Inputcompany name, domain, home market (country / language / currency)
DoBind the per-environment Area values the layer deliberately did not serialize: AreaFrontpage (the site-root page id), AreaEcomShopId (SHOP1), AreaEcomLanguageId, AreaEcomCurrencyId, AreaEcomCountryCode, AreaDomain. Set AreaTypographyId and AreaButtonStyleId in the SAME save_areas call — an area with those empty renders the whole site as unstyled 16px serif, because Swift's heading and body scale is driven by the area's typography CSS variables. On this Distribution the correct ids are default / default, not the fonts / buttons that dw-swift-page-blocks gotcha 16 names.
Assertget_area_by_id echoes the bound values, and after a host restart GET <site-root>/ resolves to the frontpage rather than 404. Gate after ANY area save: fetch / and require both <header data-swift-page-header=...> and <footer data-swift-page-footer=...> to be present.

Never create a new area in the branding flow. Repurpose area 3.

If a second language is in scope, the ordered sub-flow is (dw-content-localization/SKILL.md:27-91):

get_areas / get_master_areas          resolve the website the user means
find_unresolvable_item_pages(areaId)  create_language_version FAILS on these — get the list
                                      first and hand it to the user; do not guess a fix
create_language_version(sourceAreaId, culture, name)
loop:  get_translatable_content(areaId, skip, take: 20)  ->  apply_translation(units)
       WHILE HasMore is true, call again with skip = NextSkip

Stopping while HasMore is true leaves the rest of the page — typically the accordion at the bottom — in the source language, which is exactly the defect an un-looped call ships. Read per-page/field errors as you go and re-apply only the failures; do not halt on an individual field error. Capture every mirror's PageMenuText before a bulk change and diff after: a reorder_pages or a save_pages re-parent de-translates mirror labels silently, and set_page_menu cannot repair it — the repair is set_page_item_fields {pageId: <mirrorId>, fields: {Title: "<translated label>"}}.

Step 4 — Catalog and PIM data model

Skilldw-demo-pim (dynamo: false) references/canonical-setup-order.md §1 Variant A; dw-pim-modelling (dynamo: true) references/structural-model.md; dw-pim-completeness (dynamo: true); dw-pim-workflow (dynamo: true)
Inputthe customer's product families, attributes and sample data files from customer-context/

Ordered tool sequence, Variant A (Storefront-first — this site has a storefront):

 1  save_currencies, save_countries, save_languages
 2  save_shops              SHOP1 is a WHOLE-ENTITY REPLACE — read it back and send the
                            complete model, or a name-only save silently resets
                            ShopAutoBuildIndex, the image folder and patterns,
                            ShopOrderFlowId, ShopCompletionRules and ShopCreated. The save
                            response projects only id/name/usageType/topLevelProductGroups,
                            so the damage is invisible in the echo.
 4  save_units, save_manufacturers
 7  create_data_model_structure   (or create_categories -> create_category_fields ->
                                   save_groups -> assign_categories_to_data_models)
 8  save_groups             pass shopId on SUBGROUPS too, in the same call as parentGroupId
 9  save_variant_groups, save_variant_options
10  create_products         ignores languageId — products land on the master language
11  assign_data_model_to_products
12  patch_products_safe
13  get_product_asset_categories -> import_product_images_from_urls -> set_product_primary_image
14  assign_variant_groups_to_product -> create_variant_combinations -> get_variant_combinations
                            then fix ProductActive and ProductPrice, which
                            create_variant_combinations leaves NULL
15  (BOM) EcomProductItems — restart the host after; the ProductItem cache is startup-scoped
17  create_or_update_product_queries
21  create_or_update_completeness_rules -> assign_completion_rules_to_groups
23  create_dashboards -> get_available_widgets -> get_widget_parameters
    -> add_widgets_to_dashboards          (counts first, the grid last)
24  build_product_index -> wait_for_product_index          FINAL REBUILD, after all mutations

ID discipline: the PIM catalogue tools auto-assign entity ids and ignore ids you pass in. Capture items[].id from every create/save response and key every later step off the captured id. A blind re-run after a partial failure stacks duplicate, mis-IDed entities.

Assert: get_products count equals what you authored; one EcomShopGroupRelation row per group; verify_product_visibility on one SKU reports it visible in SHOP1; get_product_index_status shows the primary instance Completed with a non-zero documentCount; and GET <site-root>/shop shows non-zero cards with dw-error 0 and rendered rows equal to the header count.

Step 5 — Users and groups

Skilldw-users-permissions (dynamo: true) references/grant-mechanics.md, references/page-gating.md; dw-commerce-b2b (dynamo: true) references/account-shape.md
Toolssave_user_groups -> get_user_groups_by_system_name -> create_users -> assign_users_to_group -> save_user_addresses
Inputthe customer's account structure and role names, from notes/wording.md
DoBuild on the three base groups (Customers 1325, Account Admin 1270, CSR 1292) rather than replacing them — surface-swift's page permission blocks reference those ids by number. Author the floor of two personas. Give every contact in one account the SAME customerNumber: four features compare that string exactly, and a per-contact suffix silently disables account-wide favourites, CSR account listing and contract pricing. Passwords go to notes/credentials.local.md, referenced by pointer from the ledger.
Assertget_users_by_group_id returns the expected members for each group; get_users_by_customer_number on the account number returns every contact. Then, signed in as a Customers-only persona, GET <site-root>/customer-center/csr/accounts is denied. Never verify a grant as Angel / BuiltInAdmin / Administrator — those bypass every check; create a Default-type user in the target group. The permission model is cached in-process — recycle before believing a negative check.

Two ceilings to say out loud at this step rather than route around: there is no MCP password tool (create_users makes a login with no usable password — setting it is an admin screen), and there is no MCP impersonation tool (Edit user > Impersonation tab).

Step 6 — Pricing and assortments

Skilldw-commerce-catalog (dynamo: true); dw-commerce-b2b (dynamo: true) references/dc-scoping.md; dw-data-write-effects (dynamo: true)
Inputthe customer's price logic — list vs contract, quantity breaks, per-account catalogue scoping — from the pricing extracts in customer-context/
save_currencies      set the new default currency to Rate 100 BEFORE anything else; every
                     other rate is relative to it. The shipped USD$$ENU row is Rate 1 against
                     a default EUR of 100 — copy that shape and every USD price is 100x wrong.
                     Changing the default currency owes a host restart BEFORE seeding.
save_prices          customer-number-scoped contract rows are charged end-to-end by the stock
                     DefaultPriceProvider with zero custom code. A customer-GROUP-scoped price
                     (PriceUserGroupId) is NOT reachable from save_prices — say so.
force_price_recalculation
save_assortments -> assign_groups_to_assortment -> assign_products_to_assortment
                 -> assign_shops_to_assortment -> assign_permissions_to_assortment
flag_assortments_for_rebuild -> build_assortments -> get_assortments_for_build (confirm empty)
check_assortment_product_access

Assert: get_prices_by_product_id returns the contract row; the cart line for the contract customer, signed in, shows the contract amount — force_price_recalculation recomputes without a frontend user price context, so it can never prove a customer-scoped price. check_assortment_product_access reports access for an in-scope user and none for an out-of-scope one. An assortment write not followed by flag_assortments_for_rebuild + build_assortments leaves the customer seeing the old catalogue. Before switching an assortment Active on, assert its item count is non-zero: activating an empty assortment takes the whole catalogue away from everyone who holds it.

Step 7 — Checkout

Skilldw-commerce-orders (dynamo: true) references/checkout-configuration.md, references/order-states-and-quotes.md
Toolsget_payments / get_shippings -> toggle_payment_active / toggle_shipping_active -> update_payment / update_shipping -> sort_payments / sort_shippings -> create_shipping_method -> save_payment_translation / save_shipping_translation
Inputthe customer's payment terms (on-account vs card), delivery model, and any custom checkout field
DoDeactivate the stock Danish-named rows the base ships (Faktura, Kreditkort, Firmalevering, Hjemmelevering) unless the customer is Danish. Keep PAY2 Invoice active if subscriptions are in the storyline — it is the IRecurring method. A custom checkout field is an admin-screen operation: create_order_field errors on every call on this platform line.
AssertGET <site-root>/cart/ is 200, and a driven cart reaching <site-root>/cart/checkout-user renders at least one payment option and one shipping option. An all-inactive method list still returns 200. Drive the cart with a BROWSER User-Agent and follow redirects — curl and wget defaults fail both gates and the failure looks like a broken cart.

Step 8 — Verification

Skilldw-swift-page-design (dynamo: true) "Verify + summary"; dw-swift-building (dynamo: true) references/layout-verification.md; dw-demo-base (dynamo: false) references/visual-qa.md, references/pii-sweep.md
Toolsfetch_frontend_page_html, get_frontend_health, get_grid_rows_by_page_id, get_paragraphs_by_page_id
1. Render check. fetch_frontend_page_html every branded page. The body must show real content
   and NOT: raw {"Label":...} JSON, overlapping garbled text, "the selected option no longer
   exist", or "Error executing template". Reading the stored structure back is NOT enough — a
   page with perfectly valid rows and paragraphs still renders broken if one Template name,
   button value or scheme id is wrong, and that exact failure looks fine in
   get_paragraphs_by_page_id.
2. Placeholder sweep. Zero occurrences of /placeholder/i across every rendered page. That is
   the marker surface-swift deliberately planted.
3. Structure read-back. get_grid_rows_by_page_id + get_paragraphs_by_page_id match the plan.
4. Browser measurements — four defects clear every check above and still ship a broken page,
   because each needs a measurement rather than a read:
     a. Horizontal overflow at phone width. Assert BOTH
        window.innerWidth === requested AND document.body.scrollWidth === window.innerWidth.
        Either leg alone certifies a broken page: scrollWidth alone reads zero once the
        browser widens the layout viewport to fit unshrinkable content (measured:
        body.scrollWidth 652 / innerWidth 652 / requested 390 — a 262px stretch reported as no
        overflow). Read scrollWidth at TWO device widths before hunting: the same number at
        both means a fixed-width box in normal flow, which eliminates every responsive suspect
        in one reading. Name the offender by RIGHT EDGE, never by width.
     b. A control that is present and styled but sits under a card's stretched row link, so
        clicks land on the anchor. Hit-test it.
     c. An anchor coloured by its row's DECLARED color scheme rather than by the background the
        page actually paints. Measure the rendered contrast pair, not the declared token.
     d. A row that renders nothing while still paying its spacing.
   Measure BOTH auth states — Swift's mobile header renders a wider control when nobody is
   signed in. Adjudicate overflow by measurement, never from a full-page capture: a closed
   off-canvas panel parked off-screen looks identical to a stretched canvas, and an unclickable
   control looks perfect. Verify at more than one width — a wrap-dependent bug can measure
   0.0px at one viewport and fail at another.
5. PII and vendor-boilerplate sweep. BLOCKING, before the demo is shown, published,
   screenshared or handed over — true of a cleanly built demo, not only a cloned host.
   Renaming user rows fixes nothing: order snapshots, address rows, token labels, log text and
   JSON merge-field snapshots each hold an independent copy. Scan EVERY string column,
   classify by SAMPLING values (not by table name), fix, re-scan — fixing one layer exposes the
   next. Crawl the rendered corpus signed in as each persona, customer-centre order pages
   included: the order-time identity snapshot is invisible to a user-table check and to an
   anonymous crawl alike. Sweep the stock vendor boilerplate too — privacy / cookie / terms
   pages, corporate addresses, the email-recipient author list. A term-grep cannot find
   placeholder data containing none of your terms, so add locale-SHAPED patterns and keep the
   rendered-page eyeball pass as a required step. Never copy a leaked value forward into notes,
   commits, tickets or transcripts — record the class and the count.
6. Closing ledger. Run the CUSTOMISATIONS.md audit; its output is the closing
   customisation-budget slide, and the format IS the slide — do not embellish it.