Skip to main content

Data Generation (wayd-data)

wayd-data is a developer tool that generates a realistic company — people and a management tree, a cross-functional delivery hierarchy, a full project-portfolio dataset, a product catalog with its delivery history, and each ART's planning history on top of it — and seeds it into a Wayd environment through the public HTTP API. Use it to stand up a believable environment for exploring features or demoing, without hand-crafting data. Every seed run also exercises the real API end to end, so it doubles as an integration smoke test.

Project: Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli (the executable is wayd-data).

Scope

Generates the organization (people + team hierarchy), the PPM dataset (portfolios, programs, projects, tasks, strategic themes, and strategic initiatives with KPIs), the Product Management dataset (products and the dependencies between them, versions, release packages, releases, deployment environments and deployments), and the Planning dataset (planning intervals with their teams, objectives and risks). Use the org-only recipe for the organization on its own, or --skip-ppm / --skip-product-management / --skip-planning to leave out one area. Work items, sprints and objective health checks are not generated. Database reset is a separate, out-of-band operation (an Aspire dashboard command), not part of this tool.

Quick start​

The tool has four verbs: generate (write CSVs to disk to inspect), seed (generate and push through the API), ui (build, preview and seed a recipe in a browser), and recipes (list the built-in recipes; recipes show <name> prints one resolved and recipes schema prints the recipe schema). generate and seed share the same generation options.

If you would rather not start from a command line, wayd-data ui opens a page that builds the recipe for you, seeds it, and shows the command it corresponds to.

generate writes files to read, not files to post

The organization files are postable as they stand — every reference in them is a natural key the generator owns. The PPM, Product Management and Planning files are the generated model: they name portfolios, products, versions and planning intervals rather than pointing at ids, because those ids only exist once a run has created the records. seed resolves them stage by stage as it goes; there is no way to write a postable file for either ahead of a run.

# Preview: write the CSVs to a folder, no API needed
dotnet run --project Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli -- \
generate --value-streams 3 --teams 20 --out ./seed

# Seed a running environment
dotnet run --project Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli -- \
seed --api https://localhost:7021 --api-key <token> \
--company-type tech --value-streams 3 --teams 20

The API base URL is shown in the Aspire dashboard when you run the stack (cd Wayd.AppHost && dotnet run).

generate writes one file per import, named for it: employees.csv, teams.csv, team-memberships.csv and members.csv for the organization; strategic-themes.csv, portfolios.csv, programs.csv, projects.csv, project-tasks.csv, project-stages.csv, strategic-initiatives.csv, strategic-initiative-kpis.csv and ppm-finalizations.csv for PPM; deployment-environments.csv, products.csv, product-dependencies.csv, versions.csv, release-packages.csv, release-package-components.csv, releases.csv, release-contents.csv and deployments.csv for Product Management; and planning-intervals.csv, planning-interval-objectives.csv and risks.csv for Planning. An area that is switched off writes none of its files. Roles and accounts are created through the API at seed time and have no file.

Authentication​

The tool authenticates with a Personal Access Token in the x-api-key header. Create one in the app under Settings → Personal Access Tokens, then pass it with --api-key or the WAYD_API_KEY environment variable:

export WAYD_API_KEY=<token> # or set WAYD_API_KEY=<token> on Windows
dotnet run --project Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli -- \
seed --api https://localhost:7021 --value-streams 3 --teams 20

No password is ever handled by the tool, and a token is scoped, expiring, and revocable on its own.

A token acts with its account's permissions, so create it as an administrator. Before posting anything, the seed checks that the token can submit every import it splits into several files, and stops naming any it cannot — see One area can be more than one file.

Re-seeding

The import is additive and rejects duplicates, so it expects a clean database. To re-seed, reset the database first, then run seed again.

The reset is the Aspire command, and only the Aspire command. Start Aspire, then run Reset Database from the wayd-api resource's Actions → Commands in the dashboard. It resets exactly the database the API is configured with. Do not drop or recreate a database any other way — with sqlcmd, dotnet ef database drop or a script — without confirming the database by name with its owner first: a developer's user secrets usually hold several connection strings, most of them commented out, and reading the target from a file is how the wrong database gets dropped.

A reset takes your account with it, not just your tokens. The application's own database seeder creates roles, not users, and wayd-data's accounts cannot help you here — creating them needs a token, which needs an account. So the loop back to a working token is:

  1. Reset, then start the application. With no users in the database it prints a one-time bootstrap token to the log; see First-Run Setup.
  2. Create the first admin at <your-app-url>/setup, or POST /api/auth/setup with the token. This account is created with no forced password change, so it is usable immediately.
  3. Create a Personal Access Token under Settings → Personal Access Tokens, or POST /api/user-management/personal-access-tokens.
  4. Seed with that token. The seed then creates its own accounts on top of yours — see Sign-ins and roles — so after this you can sign in as any of the generated people as well.

Every step has an HTTP endpoint, so the whole loop can be scripted — the bootstrap token is the only value that has to come out of the log, and it lasts for the lifetime of that process only.

Recipes​

A recipe is a JSON file naming the knobs you care about, so a particular shape of company is something you name rather than a line of flags you remember. Six ship with the tool:

$ wayd-data recipes
Built-in recipes:

default A small tech company with a full four-year delivery window. What a run uses when no recipe is named.
demo A fixed dataset for demos and screenshots. The only built-in that pins asOf, so it reproduces byte-for-byte when paired with --random-seed.
enterprise A large non-delivery-led business, where most of the headcount sits outside the delivery structure.
large-tech A scaled product organization: several value streams, many teams, a dense portfolio, and a microservice catalog of a few hundred products with the dependencies between them.
org-only The organization on its own, with no PPM, Product Management or Planning dataset over it.
startup A young company: one product line, a handful of teams with no ART over them, and little history behind it.

The built-ins are embedded JSON under Recipes/BuiltIn/ in the CLI project, read through the same parser as a file of your own. A built-in name is tried before a file path, so a file named startup in the working directory never shadows the built-in.

Use one with --recipe <name>, or point --recipe at a file of your own.

note

Examples in this section use the short wayd-data form. From a checkout that is dotnet run --project Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli -- …, as above.

wayd-data generate --recipe large-tech --out ./seed
wayd-data seed --recipe startup --api http://localhost:5150 --api-key $WAYD_API_KEY

The three layers​

Values resolve bottom-up, and the top one that states a value wins:

  1. The shipped defaults (the default built-in) — states every knob, so a run always has a complete set.
  2. The recipe you named — states only what it cares about; anything it leaves out shows the layer below.
  3. The flags you passed — beats both.

So --recipe startup --teams 9 gives you the startup shape with nine teams. That last layer is why no generation option has a default value: an option carrying one reads back as "the user typed this" whether they did or not, which makes "an explicit flag wins" impossible to implement.

Writing your own​

Start from a built-in rather than a blank file. recipes show prints one with its extends chain already resolved, so it is both a worked example of the format and a working starting point:

wayd-data recipes show large-tech > my-company.json
wayd-data generate --recipe ./my-company.json

A recipe states only what it changes and inherits the rest:

{
"$schema": "https://wayd.dev/schemas/wayd-data/recipe.schema.json",
"version": 1,
"extends": "large-tech",
"description": "Our shape, but with a longer history and no function portfolios.",
"timeline": { "companyAgeYears": 20, "historyYears": 4 },
"ppm": { "functionPortfolios": 0 }
}

Point $schema at the schema (wayd-data recipes schema writes it out) and your editor will complete field names and flag typos as you type. The tool checks the same thing at run time and rejects a key it does not recognise:

Recipe error: Recipe 'my-company.json' could not be read: The JSON property 'teemz'
could not be mapped to any .NET member contained in type
'Wayd.Tools.DataGeneration.Cli.Recipes.OrganizationRecipe'.

A number outside the range the schema states for it is rejected the same way, naming the field ('organization.teams' is 0, and the smallest it may be is 1.), rather than being clamped into range by a generator. So is an extends cycle, or an extends that names neither a built-in nor an existing file.

That strictness is deliberate. A misspelled key that parses cleanly and does nothing is the worst thing this format could do — the run succeeds and quietly ignores what you asked for.

Areas​

Each area block carries an enabled flag, so switching one off is part of the format rather than another --skip-x flag:

{ "extends": "default", "ppm": { "enabled": false } }

The org-only built-in disables ppm, productManagement and planning. --skip-ppm, --skip-product-management and --skip-planning are shorthands for one area each, and only ever turn it off — leaving one out never switches an area back on over a recipe that disabled it.

The organization is the exception: it cannot be disabled, and saying so is an error rather than a silent no-op. Every other area is generated over it — portfolios and projects name people by employee number, and projects are scoped to a team — so a run without it produces nothing at all.

Sign-ins and roles​

Without accounts of its own, a seeded environment has hundreds of employees and one account linked to none of them, which makes every PPM rule unreachable: the account you sign in with either administers everything or leads nothing. So the seed creates roles and gives accounts to the people whose authorization actually differs.

Six roles are created, chosen to sit on real seams in the authorization model rather than on job titles:

RoleWhat it demonstrates
Read OnlyThe baseline — sees everything, changes nothing.
Team MemberWork items gate on the permission alone, with no leadership of the project required.
Project ManagerThe permission reaches every project; delivery leadership decides which ones actually change.
Delivery ManagerThe same, one level up — the portfolios and programs above those projects.
Product ManagerThe other model: product management has no Administer counterpart, so the claim alone is enough.
PPM AdministratorThe membership bypass. Waives per-record leadership across the whole domain, so it goes to one person.

Sponsors deliberately get Read Only. They are excluded from delivery leadership by design, so an account that sponsors a portfolio and still cannot change it is the other half of the same rule.

Wayd ships exactly one role — Admin — and it carries Administer ProjectPortfolioManagement. Seeding every account as Admin would switch off the rules the environment exists to exercise, which is why the seed creates a spread instead and hands the bypass to almost nobody.

Every account is created with the same password, which the recipe states:

"users": {
"enabled": true,
"password": "Test123$"
}

The password must satisfy Wayd's password rules — at least 8 characters, with an uppercase letter, a lowercase letter and a digit; the schema states the 8-character minimum.

One password for all of them is deliberate — switching between people to check a rule is the point. Wayd prompts each account to change it at first sign-in; that is a client-side prompt rather than something the API enforces, so the account works immediately either way.

--skip-users turns the area off, and --user-password overrides the password for one run.

Roles are created or updated by name, so seeding onto an environment that already has them resets their permissions to the ones above. Accounts are created one request at a time, signing in through the local Wayd provider and linked to their employee; an account the API refuses — most often an email address the environment already holds — is logged as skipped and the rest carry on.

Which employees get an account

The people holding a PPM role — sponsors (Read Only), project owners and managers (Project Manager), and portfolio and program owners and managers (Delivery Manager) — plus a sample of five project members (Team Member) and the executives, which is a few dozen rather than the whole company. Someone holding several of these gets the strongest. An account that leads nothing behaves like every other account that leads nothing, so there is little to learn from hundreds of them.

The executives are included so an organization-only run still leaves you a way in: the CEO gets the shipped Admin role, the CPO Product Manager, and the CTO PPM Administrator, the only account that holds it. Only people still employed get an account.

The timeline block​

timeline is the run's clock in serialized form, and the same values described under Reproducibility:

"timeline": {
"asOf": null,
"companyAgeYears": 5,
"teamStructureAgeYears": 2,
"historyYears": 2,
"runwayYears": 2
}

asOf: null anchors on the real today, so data straddles now. demo is the only built-in that pins it, which is what lets a demo dataset be reproduced byte for byte when paired with --random-seed.

A recipe never pins the seed. A recipe describes a shape, and baking a seed into one would make every run of it produce the same company — which is what --random-seed is for, when you want it.

The UI​

wayd-data ui opens a local page for building a recipe: pick a built-in, adjust the knobs, see what it would generate, write the CSVs, or seed it into an environment.

wayd-data ui # opens a browser
wayd-data ui --no-browser # prints the URL instead
wayd-data ui --out ./seed # where "Write CSVs" writes by default
wayd-data ui --api https://localhost:7021 # fills in the seed form's API URL

It is a second front end over the same resolver, not a second implementation. Three things follow from that, and they are the reason it exists in this shape:

  • The form is built from the recipe schema at run time. Adding an area to the recipe format puts it on the page with no change to the page. Hand-written fields would drift the first time the format grew.
  • Generation and seeding go through the same code generate and seed run, so the page and the CLI cannot produce different data from the same recipe.
  • It shows you the command. The page displays the equivalent wayd-data generate … line — or wayd-data seed … --api <url> once an API URL is filled in — with a copy button, beside the recipe JSON. The intent is that the page teaches the CLI rather than replacing it: you can stop using the page as soon as you know what to type.

Seeding from the page​

Fill in the API URL and a Personal Access Token and press Seed. The page asks you to confirm the environment, then streams the seed's log as it runs and reports how it ended.

  • The token can be pasted into the form, or left empty to use the WAYD_API_KEY the tool was started with — the form says when one is available. A pasted token wins. Either way it only travels from the page to wayd-data on the loopback address; it is never saved, logged, or put in the printed command, which leaves --api-key out because seed reads WAYD_API_KEY itself.
  • One seed at a time. Two seeds into one environment would race each other's imports, so a second press while one is running is refused.
  • Cancel stops the seed. Imports the API has already accepted still finish there, so a cancelled seed leaves a partly seeded environment — reset it before seeding again.
  • Closing the page does not stop a running seed; reopening the page's URL does not reattach to it, but the seed carries on until it ends or you stop wayd-data.

How it is reached​

Bound to a loopback address on a port the operating system picks, and gated by a token generated per run and carried in the launch URL — the same shape the Aspire dashboard is opened with. The page takes the token out of the address bar as soon as it loads and sends it as a header, so it stops appearing in history or in a screenshot. A request without it gets a 404 rather than a 401, which tells something that merely found the port as little as possible.

Nothing is served to the network, and nothing is written outside the folder you name.

Options​

Flags beat whatever the recipe said. Defaults below are what the shipped default recipe states, not values baked into the options themselves.

Every knob the recipe format states has a flag here, and a test pairs the two so neither can quietly get ahead of the other. That is what lets the UI print a command that reproduces what is on screen: anything you can compose in the page, you can also say on a command line.

OptionDefaultPurpose
--recipedefaultA built-in recipe name, or a path to a recipe file
--company-age-years5How far back the company's history reaches; nothing is generated before it
--team-structure-age-years2How long the current team structure has stood — shorter than the company's own age
--history-years2Delivery history behind the as-of date
--runway-years2Planned work ahead of the as-of date
--company-type, -ctechtech / balanced / enterprise — sets the share of employees inside the delivery team structure
--delivery-ratio(from type)Override (0..1) for the inside-teams share
--value-streams, -v3Number of value streams (product lines); larger ones become three-tier
--teams, -t18Number of leaf delivery teams (overall headcount derives from this)
--value-stream-tierAutoAuto / On / Off — whether a value stream gets a team of teams above its ARTs. Auto gives one only to a value stream with enough teams for two ARTs
--art-tierAutoAuto / On / Off — whether teams are grouped into ARTs. Auto is on
--attrition-rate0.1Share (0..0.5) of positions that change hands a year across the history; every leaver is replaced — see Attrition
--skip-usersoffDo not create application roles or sign-ins. A shorthand for a recipe that disables the users area
--user-passwordTest123$The password every generated account is created with
--skip-ppmoffGenerate only the organization; skip the PPM dataset. A shorthand for the org-only recipe
--function-portfolios2Cross-cutting business-function portfolios, on top of one per value stream
--concurrent-projects-per-art10Projects an ART has in flight at once; the total generated is derived from this across the window
--concurrent-programs-per-portfolio5Thematic programs a portfolio runs at once; the total generated is derived from this across the window
--programsAutoAuto / On / Off — whether value-stream portfolios group projects into programs. Auto leaves about one project in six standalone; On places every project in a covering program; Off generates none
--skip-product-managementoffSkip the product catalog and its delivery history
--version-interval-days14Average days between two versions of a service; web and mobile apps ship less often, libraries and tools far less
--change-failure-rate0.12Share (0..0.6) of production deployments that fail or are rolled back, averaged over the history
--packaged-art-fraction0.25Share (0..1) of ARTs that ship their services together as release packages; at least one ART when above zero
--components-per-team1.3Average components (1.3..10) each team owns and ships. 1.3 is one deployable per team with the occasional library or tool, and draws nothing extra; a microservice estate runs three to ten. Every component ships, so this also sizes the delivery history
--skip-product-dependenciesoffGenerate no product dependencies; the catalog and its delivery history are unchanged
--dependencies-per-component3.5Average products (0.5..10) each component depends on; applications depend on more, libraries on almost nothing
--hard-dependency-fraction0.6Share (0..1) of dependencies a product cannot work without, before each link's own leaning
--platform-servicesfrom the catalogShared platform services (0..24) most of the catalog relies on. Omitted, it is about one service in forty, between three and eight
--skip-planningoffSkip the planning intervals and the objectives and risks planned in them
--iteration-weeks2Length (1..4) of each iteration in a planning interval, in weeks; intervals are quarterly, so this also sets how many iterations each holds
--objectives-per-team4Average objectives (1..10) a team commits to per planning interval, stretch objectives included
--risks-per-team1.5Average risks (0..5) a team raises per planning interval
--random-seed, -rrandomFixed RNG seed for reproducible output (every area)
--as-oftoday (UTC)The date the run treats as today, which the whole timeline is anchored on. Pin it with --random-seed for byte-identical output
--out, -o./seed(generate and ui) output directory
--no-browseroff(ui only) print the URL instead of opening a browser
--api, -a—API base URL — required for seed; for ui, fills in the seed form
--api-keyWAYD_API_KEY(seed only) Personal Access Token

What gets generated​

The generator builds a coherent company top-down.

The delivery hierarchy (value stream → ART → team)​

Wayd "teams" model the cross-functional product-delivery structure (a SAFe-style shape), not the whole company org chart:

  • Value Stream — a top-level Team of Teams (a product line).
  • ART — a mid-level Team of Teams grouping delivery teams.
  • Team — a leaf delivery team.

Value streams are mixed by size: a large one becomes the full three tiers (value stream → ARTs → teams); a small one collapses to a single ART over its teams.

That is what Auto does, and two switches override it when you want a particular shape rather than the one size suggests:

OnOff
valueStreamTierEvery value stream gets a team of teams and VPs, even one with a single ARTNo value-stream teams of teams; ARTs sit at the top, their leads reporting to the CTO and CPO
artTierARTs, as AutoNo ARTs. Teams sit directly under their value stream's team of teams, or at the top when it has none, and their managers report to its VPs or to the CTO and CPO

With ARTs off, the work that was grouped by ART is grouped by value stream instead: its teams plan one set of planning intervals (rostered with the value stream's team of teams, if any), ship one product, and take on projects together — as many as the ARTs they replace would have. The startup recipe turns ARTs off, since a handful of teams rarely has one.

Who sits where (staffing)​

  • Team — individual contributors, an Engineering Manager who is also an IC on that team, and a Product Manager acting as Product Owner.
  • ART — an engineering lead (RTE) and a product lead who lead the ART but sit on no single team.
  • Value Stream — a VP of Engineering and a VP of Product.
  • Executive — a CEO with a CTO and CPO. The management tree mirrors the delivery hierarchy (team EM → ART lead → VP → CTO/CPO → CEO).

The rest of the company​

Real companies have people outside the delivery structure. --company-type sets the split:

Company typeInside the team structureRest of the company
tech (default)~85%~15%
balanced~50%~50%
enterprise~20%~80%

The non-delivery population (Sales, Customer Support/Success, Marketing, Finance, People, Legal, IT Operations) has its own management chains and no team memberships.

Realistic details​

  • Worker type — most people are regular Employees; a minority of non-manager ICs are Contractor, Contingent Worker, Intern, or Part-Time. Every manager is a regular employee.
  • Timeline — at the defaults the company is modeled as ~5 years old (companyAgeYears; hire dates span up to five years back) and the current team structure as ~2 years old (teamStructureAgeYears; team active dates within two years).
  • Former employees — people who held a position and left it; see Attrition.

Attrition​

Over the delivery history every position — engineers, managers, product owners, executives and the rest of the company alike — loses people at attritionRate a year, and every leaver is replaced. So attrition adds former employees without shrinking the company as it stands today, and it reaches the delivery teams: memberships are current state, so a leaver simply holds none.

  • A position's holders follow one another: the replacement starts two to six weeks after the leaver goes, nobody leaves in their first three months, and everyone hired falls between the company's founding and today. Leavers are generated inactive, reporting to whoever held their manager's position on the day they left.
  • The rate applies across the history (historyYears), not the company's whole age, so a 25-year-old enterprise does not bury its data under decades of ghosts. Two years at 10% is about one leaver for every five positions.
  • Past work names whoever held a role at the time. A completed project is owned, sponsored and staffed by the people in post when it closed; a completed task by whoever held the position when it finished; a risk by whoever reported it that day. Open work only ever names people still employed — an active project with an inactive owner would be a record nobody can manage.
  • Leavers get no sign-in, even when they own finished work.

How long someone stayed is known only to the generator. Wayd records a hire date and whether someone is active, and nothing in it needs the day they left.

The PPM dataset (portfolios → programs → projects → tasks)​

Unless --skip-ppm is passed, the generator layers a full project-portfolio-management dataset on top of the org. The two structures are kept independent — the delivery hierarchy (value stream → ART → team) decides who builds the work, while the investment hierarchy (portfolio → program → project) decides how it's funded and grouped — and they meet only at the project, where funded work is staffed from delivery teams:

  • Portfolios — one per value stream (named after its domain, e.g. "Payments Portfolio"), plus a couple of cross-cutting business-function portfolios (--function-portfolios) that pull projects from teams across value streams. Function portfolios draw from a single naming style per company (business function, business unit, strategic, technology, investment-based, or product-based) so they read coherently.
  • Programs — a portfolio's thematic groupings of projects (Modernization, Integrations, Client-Driven, Growth & Scale, Automation & AI, Platform & Reliability). A program is an investment structure, independent of the delivery hierarchy — it has nothing to do with ARTs. Each portfolio runs several concurrently (--concurrent-programs-per-portfolio), each 1–3 years long, spread across the window so some have finished, some are active, and some lie ahead. A project is sorted into a program by the kind of work it is (its leading verb — "Migrate" → Modernization, "Integrate" → Integrations, …), and only into a program whose window covers the project's; a minority of projects stay portfolio-direct (standalone).
  • Projects — ART-scoped and multi-team, matching how delivery actually works: a project is a subset of an ART's teams collaborating, each owning their own features. Most projects span two to five teams; a single-team project is the minority, and about one in ten reaches across ARTs within the value stream. One participating team is the owning team — it provides the Engineering Manager (owner/manager) and Product Owner (sponsor) and decides the program placement — and the project's members are the union of everyone across the participating teams. Each project gets the shared Standard Delivery lifecycle and an expenditure category. Volume is set by --concurrent-projects-per-art (how many an ART runs at once); the total is derived from the window and typical 2–9-month durations, so an ART accumulates a deep history and a full runway rather than a handful of projects. Most ART projects are named after a component the owning team builds ("Migrate Loyalty Gateway"), so they read against the product catalog; a component retired before a project starts is never its subject.
  • Project tasks — a dense work breakdown (stages → root tasks → child tasks, plus a milestone per stage) for every project that has started; proposed and future projects have none yet. Each stage's status is rolled up from its tasks (all done → Completed, some in flight → In Progress, none started → Not Started) and imported as data — the stage-status import applies exactly what the generator computes and never derives it server-side, so a real client keeps full control of stage status.
  • Strategic themes — a handful of active themes, tagged onto programs and projects.
  • Strategic initiatives + KPIs — one per portfolio, linking a few of its projects, each with a couple of KPIs.

The four-year timeline. At the defaults, work spans two years of history (--history-years) and a two-year runway (--runway-years) from today, and each item's status follows its position on that window: finished in the past (Completed, a few Canceled), in flight around today (Active), and not-yet-started in the future (Proposed / Approved). Task progress and status track the same timeline.

Each portfolio, program and project also carries the dates it moved — CreatedOn, and ActivatedOn and ClosedOn where its status reached them — which are separate from the Start/End timeline it plans to run over. The import rejects a date for a status a record never reached, so the generator emits exactly the ones each status allows: a proposed project gets no activation date, and only a closed one gets a closing date. Records are proposed a few weeks to a few months before they activate, which for the earliest of them is before the window itself — work proposed before the window is exactly what a creation date is for.

For projects this is what dates the status history: every replayed transition is stamped from these columns, so a seeded project's history reads as its own timeline rather than as the moment the seed ran. Portfolios and programs record no transition dates yet, so theirs are validated and then go no further than picking which transitions to replay.

Settings prerequisites. Expenditure categories and the project lifecycle are settings, not imports. The seed verb bootstraps them through the normal settings API (create-or-get by name, and it activates the lifecycle) before importing projects, which reference them by id. They are not written as CSV by generate.

Why some historical work is "finalized" separately

The domain only lets work be added to an active program or portfolio, but only lets one be closed once everything inside it is closed. So historically-complete programs and portfolios are imported active and then closed by a final finalize pass, after their contents exist. The generator emits the finalize rows automatically; you'll see a "Finalizing …" line at the end of a seed run when any apply.

The Product Management dataset​

Unless --skip-product-management is passed, the generator builds a product catalog over the same delivery hierarchy, and the history of how it shipped. Wayd has no link between a product and a team, so the connection is in the names: a component is named for the team that owns it and its description says so.

  • Catalog — a Product Line per value stream ("Growth Cloud"), a Product per ART, and under it the components each team owns. Every team owns something it deploys — a Service or a web Application — and some also publish a Library or a Tool; about one product in three has a mobile app. Tags use the seeded Platform category. A few components are Sunset (shipping rarely since a sunset date) or Retired (nothing after a retirement date), and a few Concept services have only a planned first version.

  • Environments — each product line gets -dev, -test, -staging and -prod environments. About half also have a -uat environment retired partway through the history, which only finished deployments point at.

  • Versions — each component ships on its own cadence (--version-interval-days for a service; web apps about 1.4×, mobile 2×, libraries and tools 3× or more). Every team works at its own tempo, and teams speed up across the history. A version released before today has a target date and cut and released moments (a quarter of them slipped a few days); one cut but not released is Ready; ones up to 90 days ahead are Planned. Nothing ships in a weekend or the year-end change freeze.

  • Deployments — every shipped service or web-app version walks its line's environments, promoting the same build from dev to production, and production receives it on the day the version was released. A failure outside production is retried with a new build. In production a deployment fails or is rolled back at --change-failure-rate, scaled by team and falling from about 1.5× at the start of the history to 0.6× by today; a rollback is followed by a hotfix patch version a few days later. One recent version per line is still deploying to staging.

  • Release packages — --packaged-art-fraction of the ARTs ship their services together on a train every few weeks instead. Each package's manifest lists every service, Changed or CarriedForward, and the package, not the services, is what walks the environments. Every manifest line names a version that exists, so it is linked rather than stored as text.

  • Releases — each product line announces monthly or quarterly: the last version of each customer-facing component and every package shipped in the period, released a few days after the period closes. The current and next period are still planned.

  • Dependencies — unless --skip-product-dependencies is passed, products record what they rely on, shaped like a real estate's graph rather than a uniform scatter:

    • Hubs. With three or more value streams, one line is the platform — a platform-sounding domain (Identity, Data Platform, Developer Experience, Integrations, Messaging, Notifications) when the org has one — and a handful of its services become the shared ones, named for what they are: Identity Service, API Gateway, Event Bus and on down the list (--platform-services). A running service that already carries one of those names becomes that hub. Each other line gets one hub of its own. A smaller catalog has no platform line, and its hubs are spread across the lines.
    • Shape. Applications rely on services, services on other services and the platform, libraries on almost nothing; a random rank among services lets chains run several links deep across lines. About four links in ten cross a product line, including nearly every link to a platform service, and the most fundamental hubs draw the most — in large-tech, Identity Service is relied on by about four components in ten. A few service pairs rely on each other, the return direction always soft, and about one product in twenty holds a link of its own rather than through a component.
    • Strength. --hard-dependency-fraction leaned by what the link is: an application on its own backend, anything on identity or the gateway, and a library are nearly always hard; notifications, search and analytics usually soft.
    • History. A link starts no earlier than both ends first shipped. Every link into or out of a retired component ends before it retires; about half the consumers of a sunset component leave during its sunset; a few links churn; and a few change strength partway — two rows, the first ending the day before the second starts, mostly soft becoming hard.

    --components-per-team is what makes the map worth designing against: at its 1.3 default a catalog has a product per team or so, and large-tech sets it to 4 for about 270 products and 900 links. It draws nothing at the default, so a seed pinned before the knob existed generates the same catalog; picking the hubs renames a few services, so a pinned run that generates dependencies names those differently from one that does not.

What that is for is the delivery metrics page: deployment frequency climbs toward today and change failure rate falls, rather than both sitting flat. Wayd does not compute lead time yet, but the data already carries what it would read — every production deployment follows its version's cut.

The feature flag

Product Management is seeded disabled, and every one of its endpoints — the imports included — answers 404 until it is enabled. When the area runs, seed switches the product-management flag on first and says so in the log.

The Planning dataset​

Unless --skip-planning is passed, the generator gives every ART a planning history over the same delivery hierarchy.

  • Planning intervals — each ART runs its own series, named {ART} PI {year}.{quarter}, on one quarterly calendar every ART shares. A planning year starts on the last Monday of January — companies rarely plan over the holidays, or before the year's funding is settled — and holds four intervals of whole iterations, the spare ones going to the second and fourth: with two-week iterations that is six, seven, six and seven, which fills 52 weeks, so each interval starts at about the same time every year. In a 53-week year the extra week goes to the last interval, whose final iteration is cut short. Wayd generates the iterations from --iteration-weeks, prefixed {yy}.{quarter}., the last one Innovation and Planning. Intervals run back to back from the start of the history, one is always in progress today, and the next one is scheduled when the runway reaches it. Each is rostered with the ART itself and every team in it.
  • Objectives — each team commits to --objectives-per-team per interval, give or take one (3 to 5 at the default); the ART itself plans none. Half the teams that plan three or more hold the last back as a stretch objective. Each is worked from the start of one development iteration to the end of the same or a later one, and is named for a service or application the team owns in the product catalog ("Deliver saved searches in Loyalty Gateway"). Objectives of an interval that has ended are all closed — Completed, Missed at the end, or Canceled partway. The current interval's are as far along as the days elapsed suggest, and the next interval has none, because objectives are set at planning. Teams keep more of their commitments as the history approaches today, so predictability rises rather than sitting flat.
  • Risks — each team raises about --risks-per-team per interval, reported by someone on the team on a day that has passed, sometimes naming a dependency on another team in the ART or one of its own components. Most are closed within a few weeks under a ROAM category; the rest stay open as Owned, Accepted or Mitigated with a follow-up date ahead. Nothing reported more than a few months ago is left open.

Objective health checks are not generated. There is no import for them, and the endpoint that creates one records the signed-in user's employee as the reporter and stamps the real time rather than a date in the history.

Reproducibility​

Every run resolves a concrete seed (random when --random-seed is omitted) and prints it:

Using seed 193051458 as of 2026-06-15 (pass --random-seed 193051458 --as-of 2026-06-15 to reproduce this data).

When a recipe was named, the line includes its --recipe too, since the data depends on it as well.

Re-run with both the seed and the date to reproduce byte-identical data, for a given build of the tool. The seed pins the randomness, not the algorithm: if the generator changes, the same seed produces different data.

Both are needed because they pin different things, and either alone reproduces nothing. --random-seed fixes the draws; --as-of fixes the date the whole timeline hangs off. Leave --as-of out and it defaults to today, which is usually what you want — generated data straddles now, so there is history behind and runway ahead — but it also means tomorrow's run places every record a day further along, and flips anything that crossed today from active to complete.

What "reproducible" means here

The seed reproduces the generated data — the same names, emails, employee numbers, team codes, and hierarchy. It does not reproduce the database primary keys. Wayd assigns IDs (and other server-side keys) when the rows are created through the API, so seeding the same data twice yields different database IDs each time. Use the generated business keys (employee number, team code) to correlate across runs, not the database IDs.

How it works (and why)​

The tool never touches the database directly — it POSTs CSV to the API's import endpoints (the same endpoints documented in the Organization, PPM, Product Management and Planning Bulk Import guides), and each import handler creates rows through the owning domain's commands. This is deliberate: Wayd domains keep aligned local copies of shared concepts (creating an Organization.Team replicates it into the PPM, Planning, and Work read models via domain events). Going through the domain path means one SaveChanges fires those events, so the copies stay consistent — a direct insert would leave them empty. A useful side effect: seeding proves the real API and cross-domain wiring work.

Areas, and the order they run in​

A seed is a set of areas, each one a unit that reads what it needs, writes its file, waits for the import to finish, and publishes the ids it created. An area declares what it depends on; the runner derives the order from those declarations rather than from a list someone maintains by hand.

That indirection earns its place because the order is not a preference. A program cannot be created before its portfolio, staffing resolves roles it does not create, and the finalize pass can only close a program once its projects are closed. Stating the dependency keeps it from drifting away from the ordering as areas are added — and adding one means declaring what it needs, not finding the right line to insert a call at.

The areas, and what each one waits on:

teams → team hierarchy
employees + teams + team member roles (created as needed) → team staffing

employees → portfolios → programs (+ strategic themes)
→ projects (+ expenditure categories and lifecycle, the settings bootstrap)
→ project tasks → project stage statuses
→ strategic initiatives (+ KPIs) → finalize (close historical programs/portfolios)

product-management feature flag → deployment environments
→ products → product dependencies
→ versions → release packages (+ manifests) → releases (+ contents)
→ deployments (after environments and packages)

team staffing → planning intervals (+ rosters) → planning interval objectives
→ risks

application roles → user accounts (after employees)

Areas the graph leaves unordered run in the order they are registered in SeedRunner.Areas, so a run is reproducible and reads top-down; independent areas interleave, so the settings bootstrap and the strategic themes, for instance, post before team staffing does. An area that has nothing to do — one whose dataset was switched off, or that generated no rows — is skipped.

Product Management depends on nothing in the organization or PPM areas — it references no employees — so it only needs its own order. Versions come before packages because a manifest line links to a version only if the version already exists, and releases come last because a release is accepted as released only once everything it carries has been saved as shipped.

Planning references teams and employees by id, but it resolves teams against Planning's own copy of them, which Organization fills asynchronously after the teams import. A team that has not landed there yet rejects the row — and with it the whole file — so the Planning areas also wait on staffing, which runs several imports later and leaves the copy time to catch up.

One area can be more than one file​

An import rejects a file past its row cap outright, and the atomic ones — the team, team-hierarchy, staffing, strategic-theme and Planning imports — cap at 10,000 rows. An atomic import cannot be split server-side, so that cap is the only thing bounding a run that is applied as one transaction; it is the server's guard against an arbitrary client, and the seed is a client like any other. The PPM and Product Management imports apply group by group (deployments row by row) and save chunk by chunk, so the cap does not bound a run there, but it stays at 10,000 until a larger file has been proven end to end.

The seed does not keep its own copy of those caps. Before posting anything it reads each import's maxRows from GET /api/imports/definitions and fills every file up to it, so an import that changes its cap changes how the seed splits with no change here. Every import the seed splits must be one the token can submit, so a token missing one of those permissions fails the run up front, naming each import, before any area has posted a file.

The task file is the one that passes it: a large-tech breakdown across a couple of thousand projects runs to around 63,000 rows. So that area sends several files, and the run log says so:

Importing 62976 project tasks in 7 batch(es)...
batch 1 of 7: 9994 tasks
batch 2 of 7: 9998 tasks
…

The split falls on project boundaries, and that is not a convenience — it is the unit the import itself applies by. Rows within a project are not independent: a child names its parent by that parent's ImportId in the same file, and the per-project task number advances as rows are applied. Cutting a project across two files hands the second one a child whose parent it never saw. A single project larger than the cap is left whole and rejected rather than split, because a named failure beats a file that imports a broken tree. Projects and stages are batched the same way, by project, though neither comes near the cap at the built-in sizes.

Product dependencies and versions are batched by the product, which is also the unit those imports apply — each product's rows save together or not at all — so a product is never split across two files. Release packages, releases and deployments are batched by package (with that package's manifest lines), by release (with its contents) and by what was deployed; each of those applies one record at a time, so the batching only keeps related rows in one file. Deployments are the one that needs it: a large-tech seed produces more than 10,000. Products and environments are always one file each, because a product's parent has to be a row in the same file, which keeps every tree — the unit the products import applies — whole. Planning intervals, objectives and risks are batched too — by interval, by interval and by team — though none of them comes near the cap at the built-in sizes.

Post, wait, read back, substitute​

An import answers with its run, having waited a few seconds for it: a small file comes back finished, a large one still queued or running — taken, not applied. Each area therefore polls that run to completion and reads its row outcomes back before the next area writes anything, because those outcomes are where the created ids are. It polls every half second and gives up after ten minutes, saying the import runner may not be processing the queue.

Each row is submitted under an ImportId set to the generator's own handle for that record — a portfolio's name, a project's key — so the ids come back keyed the way the next file already refers to them, with no second mapping to keep in step.

Every file in a seed is also posted under the same submission group — a GUID the seed draws once per run and prints at the start (Submitting every file under import group …). Each run the API answers with carries it as submissionGroupId, so the twenty or more runs one seed produces can be found together afterwards rather than picked out of everything else the environment has imported.

Each import applies as its definition says — the employee and deployment files row by row, the PPM and the other Product Management files group by group, the rest all-or-nothing — and the seed aborts on anything short of a clean run in every case, naming the rows that were rejected by their import id. A partly applied PPM or Product Management file is a generator bug rather than something to carry on from: the next stage resolves ids from this run's outcomes, so a missing record would surface far from its cause. References resolve by natural key where a record has one — employee number, team code, project key — and by id otherwise. Pass --skip-ppm to stop after the organization; nothing in the organization depends on a resolved id, so that path is unaffected by any of this.

Extending the tool​

Paths below are relative to Wayd.Tools/DataGeneration/src/Wayd.Tools.DataGeneration.Cli. The tests in Wayd.Tools/DataGeneration/tests/Wayd.Tools.DataGeneration.Cli.Tests fail until every step is done.

Adding a knob touches the format, its defaults and its flag together:

  1. Add the property to its area's record in Recipes/Recipe.cs, nullable, so a layer can leave it unstated, and layer it in Recipes/RecipeLayering.cs, which merges field by field.
  2. Describe it in Recipes/recipe.schema.json with its range and an x-cli-flag naming the flag — or x-cli-no-flag with the reason there is none. RecipeBounds enforces the range from here, and the UI builds its field and its printed command from here.
  3. Copy the schema to docs-site/static/schemas/wayd-data/recipe.schema.json, the URL every recipe's $schema points at. RecipeLibraryTests fails while the two differ.
  4. Give it a value in Recipes/BuiltIn/default.json, unless leaving it unstated means something (as platformServices does). A required knob the defaults do not settle fails the run, and RecipeLibraryTests with it.
  5. Add the option to GenerationOptions — declared with no default, listed in All, and carried into the recipe in Compose. GenerationOptionsTests pairs every flag with the schema's x-cli-flags.
  6. Settle it into the generator's options in Recipes/ResolvedRecipe.cs, and add its row to Options on this page.

Adding an area means subclassing SeedArea (under Seeding/Areas/) with a stable area.thing name and the names of the areas it depends on, and registering it in SeedRunner.Areas. An area that may need more than one file overrides BatchedImport with its import's key and splits its rows with Batch, so the row cap is read from the server and checked against the token up front.

Data conventions​

Generated data is fictional — RFC-reserved domains (acme.example) and invented names and numbers. Never seed values copied from real payloads or third-party fixtures.