Skip to content

Schemes

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.

FieldRequired whenWhat you see if missing
AddressAlways”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.

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.

The Description field is capped at 75 characters with “Too long.”

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.

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.

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.

Most development types count toward the scheme total whenever they exist. Two types are filtered by status:

Development TypeCodeCounts toward SchemeSizeGross/Net when…Contribution
Most types (New Build, Refurbishment, etc.)AlwaysPositive
ReductionA.05Status starts with D (Under Construction) or E (Complete). Excluded for Proposed, On Hold, Withdrawn, etc.Negative — subtracts from the parent total
ExtensionA.04Status 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.

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.

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.

The address must be present. Everything else is optional at save time.

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.”

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%.”

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.

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.

  • 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.

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.

  • 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 related SchemeCompany.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 in src/common/models/Helpers/DevelopmentAggregationRules.cs and are applied by src/services/api/app/app.api/Services/Conversion/SizeAggregationService.cs
  • List-view mapper: src/common/models/Services/QueryViews/SchemeListViewMapper.cs