Schemes
Overview
Section titled “Overview”A scheme is the unit of “what’s being built or used at this address”. One scheme groups one or more developments, holds the cast of companies (developer, owner, operator, etc.), and aggregates sizes up from its developments. The required structure is light — every scheme must have an address — but a scheme that lacks developments or companies will report a low completeness score.
Save is only enabled once you’ve changed something.
Required fields
Section titled “Required fields”| Field | Required when | What you see if missing |
|---|---|---|
| Address | Always | ”Please select an Address.” |
The other fields aren’t required for save, but several drive the completeness score that surfaces on every scheme card — see After save.
Field-level rules
Section titled “Field-level rules”Scheme name max 255 characters
Section titled “Scheme name max 255 characters”The Name field rejects values longer than 255 characters with “Scheme Name too long.” Names are optional — a scheme can be saved without one — but unnamed schemes display by address.
Description max 75 characters
Section titled “Description max 75 characters”The Description field is capped at 75 characters with “Too long.”
Sizes must be non-negative
Section titled “Sizes must be non-negative”The Planning Size and Completion Size fields accept 0 or higher. Sizes are normally aggregated automatically from the scheme’s developments rather than typed directly: a scheme’s size mirrors the sum of its developments’ sizes.
Where this lives: src/services/web/src/lib/validation/scheme.ts.
Cross-field rules
Section titled “Cross-field rules”Address is required (linked entity or numeric ID)
Section titled “Address is required (linked entity or numeric ID)”A scheme without an address fails validation. The form accepts the address two ways: as a numeric AddressId (used by the create-from-address flow) or as a linked Address entity picked through the entity picker. Either is sufficient; both is fine.
Scheme closure is derived from Developments
Section titled “Scheme closure is derived from Developments”A scheme doesn’t carry its own “Is Closed?” flag any more. It reads as Closed when every development on it sits on the Closed DevelopmentStatus row (matched by name, so the planned move of the Closed row under the Complete branch doesn’t change the rule). The Closed Date badge takes its value from the developments’ shared ClosedDate.
Setting one development on the scheme to a closed status propagates the status and ClosedDate to every sibling on the scheme, and ticks IsRetired (with the same date) on every occupier event linked to the scheme. Investor events are deliberately untouched — closure is occupant-centric, not deal-centric.
Closure is one-way: re-opening a single development after the cascade does not roll the siblings or occupier events back. To re-open a scheme, edit each sibling/occupier event manually.
Conditional UI
Section titled “Conditional UI”Auto-aggregated sizes
Section titled “Auto-aggregated sizes”Scheme Size Gross and Scheme Size Net are derived from the scheme’s developments — they aren’t typed in. Add a development with a use of 1,000 sqm and the scheme size jumps to reflect it. Edit the development; the scheme size follows. The aggregation chain is use → development → scheme.
This is why the size fields on the scheme detail page are read-only: the editable values are on each underlying use.
Which developments count
Section titled “Which developments count”Most development types count toward the scheme total whenever they exist. Two types are filtered by status:
| Development Type | Code | Counts toward SchemeSizeGross/Net when… | Contribution |
|---|---|---|---|
| Most types (New Build, Refurbishment, etc.) | — | Always | Positive |
| Reduction | A.05 | Status starts with D (Under Construction) or E (Complete). Excluded for Proposed, On Hold, Withdrawn, etc. | Negative — subtracts from the parent total |
| Extension | A.04 | Status starts with E (Complete). Excluded for Proposed and Under Construction. | Positive |
Practical example: a refurbishment recorded as “−2,000 sqm of obsolete office removed (A.05 Reduction), +5,000 sqm of new office added (A.04 Extension)” only nets out to +3,000 once both developments hit Complete. While the Reduction is Under Construction it already pulls the scheme size down by 2,000 (Reductions count from D); the Extension’s +5,000 is held back until Complete.
The same filter is applied by the SchemeDetail “Total by use” table on the frontend, so the headline SchemeSizeGross/Net and the per-use breakdown stay in sync.
The single source of truth for these rules is DevelopmentAggregationRules.ShouldExcludeFromSchemeTotal in src/common/models/Helpers/DevelopmentAggregationRules.cs. Both the SaveChanges interceptor and the SchemeDetail panel route through it.
Building type drives the Companies rule
Section titled “Building type drives the Companies rule”Whether a company in the Operator role counts towards “at least one company” depends on the scheme’s Building Type:
- Hotel schemes (Building Type = Hotel): every company role counts, including Operator. A scheme with only an Operator listed counts as having “at least one company”.
- All other building types: Operator roles don’t count towards the “at least one company” check. You need at least one Developer, Investor, Owner, or other non-Operator role for the scheme to be considered complete.
This is a completeness-score rule, not a save rule — see After save. A scheme without any companies saves fine; it just scores lower.
Share % on the Owner list
Section titled “Share % on the Owner list”The Owner list shows a Share % column whenever there’s at least one owner, mirroring the Investor list on investment events.
See Investment Events for the equivalent rule and the matching server-side per-role cap.
On save
Section titled “On save”Required-field check
Section titled “Required-field check”The address must be present. Everything else is optional at save time.
One unknown company per role
Section titled “One unknown company per role”Across all the role groups on the scheme, you can only mark a role as Unknown once. Two Unknown Developers on the same scheme are rejected with “Only one unknown company is allowed per role.”
Per-role share limit
Section titled “Per-role share limit”The server caps the total percentage share within each role at 100%. If your owner shares add up to more than 100% you get “Total percentage share per role cannot exceed 100%.”
Market-boundary auto-sync
Section titled “Market-boundary auto-sync”After the scheme is saved, the system re-synchronises its SchemeMarketBoundary links by spatial intersection: it figures out which market boundaries the scheme’s address sits inside and updates the links accordingly. You don’t pick these by hand — they follow the address.
If the address itself moves later, the same sync re-runs.
Where this lives: src/services/api/app/app.api/Services/Model/SchemeMarketBoundaryService.cs.
Concurrency check
Section titled “Concurrency check”The save carries the RowVersion of the scheme you loaded. If another user has updated it since, you get a concurrency error. See Concurrent edits.
After save
Section titled “After save”- A SchemeCreated or SchemeUpdated event fires; downstream services pick it up.
- The scheme’s row in the SchemeList query view is upserted so list pages reflect the change.
- An audit log entry records who saved what.
Statistic columns are typed by unit category
Section titled “Statistic columns are typed by unit category”When a user adds a Statistic column on a scheme (Settings → Statistics → “add statistic”), the Unit of measure dropdown is now filtered by which Statistic was just selected:
- Rent only allows Rent units (e.g. £/sqft/year).
- Vacancy & Occupancy only allows Size units (e.g. sqm, sqft). The unit applies to the Total Size / Occupancy / Vacancy components; the Occupancy Rate and Vacancy Rate sub-statistics are always shown as % regardless of the picked unit (existing behaviour — they’re pinned by the seed).
- If the user changes the Statistic after picking a unit, the unit selection is cleared so a value that’s now filtered out of the dropdown can’t survive the switch.
The category gate is also enforced server-side — API callers that POST a mismatched (Statistic, SizeUnit) combination get a 400 with a message naming the required and actual categories. Existing columns are untouched: the unit on a column is immutable post-create (delete the column to change it), and the gate is keyed on a per-Statistic UnitType classifier so future Statistics can opt in or out by setting / leaving it null.
Land-area units are hidden from the Size dropdown on this picker. Acres and Hectares are valid SizeUnit rows with UnitType = "Size" but they’re land-area measures, not building floor area, so they’re filtered out of the add-column picker here. They remain available in other contexts where they’re meaningful (e.g. Investment Event sizes for ground-up land plays). The denylist is hard-coded next to the picker — short, explicit, and obvious enough at the use site that we haven’t introduced a SizeUnit sub-classifier column. If a second picker ever needs the same hide, promote it to a SizeUnit.IsLandArea (or similar) flag on the model and seed it on the data file at the same time.
Where this lives: src/common/models/Models/StatisticField.cs (UnitType column), seed values in src/data/app/Scripts/SeedStatisticFields.sql, server-side gate in CreateSchemeStatisticFieldCommandHandler.cs, frontend filter + STATISTICS_EXCLUDED_SIZE_UNIT_NAMES denylist in src/services/web/src/lib/components/panels/SchemeStatistics.svelte via the unitTypeFor helper in compound-statistics.ts.
Completeness score inputs
Section titled “Completeness score inputs”The completeness score that shows on every scheme card weighs the following:
- Name
- Address (already required)
- Building Type
- Scheme Size Gross (weight × 2)
- Is Verified flag
- At least one development present
- At least one company present — Operator counts for Hotel schemes; for other building types Operator roles are excluded from the count
These aren’t save requirements — a scheme can persist with any of them empty — but they bring the score down until filled. See Completeness score.
Related rules
Section titled “Related rules”- Developments — what feeds into a scheme. Date orderings, use-size caps.
- Addresses — schemes attach to addresses. Market-boundary sync rules live here too.
- Companies — the cast of a scheme. Role-specific rules.
- Investment Events — the per-role share limit and unknown-per-role rules are shared.
- Market Boundaries — what a SchemeMarketBoundary link points to.
- Completeness score — exactly how the scoring weights work.
- Concurrent edits — RowVersion mechanics.
Where this lives
Section titled “Where this lives”- Frontend validation:
src/services/web/src/lib/validation/scheme.ts - Edit form:
src/services/web/src/lib/components/panels/SchemeEdit.svelte - Model:
src/common/models/Models/Scheme.cs(and relatedSchemeCompany.cs,SchemeAttribute.cs) - Command handlers:
src/services/api/app/app.api/Handlers/Commands/Schemes/ - Market-boundary sync:
src/services/api/app/app.api/Services/Model/SchemeMarketBoundaryService.cs - Size aggregation: defined via
[AggregatesFrom]on the model; runs in the SizeTriplet interceptor at SaveChanges. Per-type/status exclusion rules live insrc/common/models/Helpers/DevelopmentAggregationRules.csand are applied bysrc/services/api/app/app.api/Services/Conversion/SizeAggregationService.cs - List-view mapper:
src/common/models/Services/QueryViews/SchemeListViewMapper.cs