Skip to content

RestAssured — System Specification (Baseline v0.1) ​

  • Status: Draft baseline · Date: 2026-09-24
  • Authoritative decisions: ai-docs/decisions/ (ADRs win over this document on conflict; fix this document when they diverge).

1. Scope ​

RestAssured manages club-owned and privately-owned assets (instruments, uniforms/clothing, accessories, cases) for music clubs, tracks who holds what, and forecasts depreciation, maintenance, and replacement budgets. It is multi-tenant, bilingual (nl/en), tenant-themable, and GDPR-aware.

Actors ​

ActorScopeDescription
Super AdminPlatformOperates the deployment: provisions/suspends tenants, platform defaults, SMTP.
Tenant AdminTenantClub administrator; holds all tenant permissions (system role).
Inventory managerTenantHas an account and one or more custom roles: board member, quartermaster, instrument manager, viewer…
Member (reference)TenantThe majority. No account; exists only as a tenant_memberships row so the club can see who has what (e.g. a child borrowing a clarinet). Never logs in.

2. Data model ​

2.1 ER diagram ​

2.2 Key constraints ​

ConstraintDefinition
Tenant isolationRLS ENABLE + FORCE on all tables with tenant_id; policy per ADR-0001.
Composite FKse.g. assignments(tenant_id, asset_id) → assets(tenant_id, id).
One user per tenantUNIQUE (tenant_id, user_id) WHERE user_id IS NOT NULL on memberships.
One open assignment per assetUNIQUE (tenant_id, asset_id) WHERE returned_at IS NULL.
Assignee xor locationCHECK ((membership_id IS NULL) <> (location_id IS NULL)).
Private ownershipCHECK (ownership = 'club' OR owner_membership_id IS NOT NULL).
Serial uniquenessUNIQUE (tenant_id, lower(coalesce(brand, '')), lower(serial_number)) WHERE serial_number IS NOT NULL: the same serial from the same brand is the same instrument.
Tag uniquenessUNIQUE (tenant_id, lower(tag)) WHERE tag IS NOT NULL (club inventory number).
Deleting assetsOnly assets without any assignment history; others are set to retired, lost or sold.
Money*_cents bigint CHECK (>= 0).
Yearspurchase_year BETWEEN 1900 AND extract(year from now()) + 1.
Audit append-onlyra_app has INSERT, SELECT only on audit_log, erasure_log.

2.3 Spreadsheet field mapping ​

Spreadsheet (nl)Fieldi18n key
Categoriecategory_idassets.field.category
Merkbrandassets.field.brand
Model / Typemodelassets.field.model
Serienrserial_numberassets.field.serial_number
Omschrijvingdescriptionassets.field.description
Aanschafprijspurchase_price_centsassets.field.purchase_price
Aanschafjaarpurchase_yearassets.field.purchase_year
Verzekerde waardeinsured_value_centsassets.field.insured_value
Eigendom (Vereniging/Privé)ownershipassets.field.ownership
Waaropen assignments row (member or location)assets.field.location_assignee

3. Authorization ​

3.1 Hierarchy ​

3.2 Scope capabilities ​

CapabilitySuper AdminTenant AdminCustom role (if granted)
Create / suspend / archive tenants✅❌❌
Platform defaults (categories, forecast defaults), SMTP✅❌❌
Provision first Tenant Admin✅❌❌
Read tenant dataOnly via audited support session✅per permission
Tenant settings & theme❌✅tenant:manage_settings, tenant:manage_theme
Define roles / edit matrix❌✅roles:manage (no escalation)
Issue reset / login links❌ (platform users via own flow)✅members:reset_password
Anonymize memberAccount erasure only (on verified request)✅members:anonymize

3.3 Default permission matrix (tenant templates, editable) ​

Legend: ✅ granted by default · — not granted. Tenant Admin (system) holds all.

PermissionBoard Member (Bestuurslid)Quartermaster (Materiaalbeheerder)Instrument Manager (Instrumentbeheerder)Viewer (Meekijker)
assets:view✅✅✅✅
assets:create—✅✅—
assets:edit—✅✅—
assets:delete—✅——
assets:view_financials✅✅——
assignments:view✅✅✅✅
assignments:manage—✅✅—
members:view✅✅✅✅
members:manage✅———
members:invite✅———
members:reset_password ⚠————
members:anonymize ⚠————
members:export_data✅———
roles:view✅———
roles:manage ⚠————
financials:read_forecasts✅✅——
financials:configure_forecasts✅———
tenant:manage_settings————
tenant:manage_theme————
audit:view✅———
data:import—✅——
data:export✅✅——

⚠ = dangerous; confirmation required to grant; reserved for Tenant Admin by default.

3.4 Request authorization pipeline ​


4. Tenant theme injection ​

4.1 Server render (first paint) ​

4.2 Tenant switch (client) ​

  1. User picks a club in the TenantSwitcher → navigate to /t/{newSlug}/….
  2. With client-side navigation: fetch('/t/{newSlug}/theme.css', {headers: {'If-None-Match': etag}}).
  3. Replace #ra-tenant-theme text content; swap logo src; update <html lang> if the tenant default locale applies.
  4. Without JS: a full page load does steps 1–3 server-side.

4.3 Theme save (Tenant Admin) ​

  1. Editor posts typed values; server validates format and enums.
  2. Server computes relative luminance and contrast of text on primary/secondary; rejects < 4.5:1 with errors.theme.contrast_too_low + measured ratio.
  3. Persist, increment version, invalidate cache, write audit entry.

4.4 Serialized token block ​

css
:root{
  --ra-color-primary:#1f4e79;
  --ra-color-primary-contrast:#ffffff;
  --ra-color-secondary:#c9a227;
  --ra-color-secondary-contrast:#000000;
  --ra-color-accent:#1f4e79;
  --ra-radius:6px;
  --ra-font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  --ra-space-unit:8px;
}
/* in app.css (static): */
.btn-primary{background:var(--ra-color-primary);color:var(--ra-color-primary-contrast)}
.btn-primary:hover{background:color-mix(in oklab,var(--ra-color-primary) 85%,black)}

5. GDPR anonymization ​

5.1 Tenant-level anonymization sequence ​

5.2 Account erasure ​

  1. For each active membership: run §5.1 in that tenant (each tenant's own transaction).
  2. UPDATE users SET email = 'erased+'||id||'@invalid', password_hash = NULL, preferred_locale = NULL, erased_at = now(); delete sessions and tokens.
  3. Platform audit entry (ids only).

5.3 Display rule ​

membership.status = 'anonymized' ⇒ render t('members.anonymized_label', {id: pseudonym_id}) (nl: "Geanonimiseerd lid #{id}", en: "Anonymized Member #{id}").

5.4 Restore safety ​

After any backup restore, replayErasures() re-applies every erasure_log row (idempotent) before the application accepts traffic.


6. Financial forecasting ​

6.1 Notation ​

SymbolMeaningSource
Ppurchase price (cents)asset; fallback insured value
y₀purchase yearasset
V, y_Vinsured value and the year it was setasset
Llifespan (years)asset → tenant category → platform default
rresidual fraction (0–1)same resolution
mannual maintenance fraction of replacement costsame
k, smajor-service interval (years), major-service fractionsame (optional)
iannual price inflationtenant setting
Yprojection year; Y₀ = reference (first) year; H horizon (years)request
R₀current reserve balance (cents)tenant setting / request

All results are rounded to whole cents with banker's rounding (half-to-even) per asset per year; totals are sums of rounded values.

6.2 Formulas ​

Age: a(Y) = Y − y₀.

Book value — straight line (default):

BV(Y) = max( P·r ,  P − (P − P·r) · min(a, L) / L )      for a ≥ 0

Book value — declining balance:

d     = 1 − r^(1/L)
BV(Y) = max( P·r ,  P · (1 − d)^a )

Replacement cost (inflation-indexed; prefers the most recent valuation):

if V and y_V known:  RC(Y) = V · (1 + i)^(Y − y_V)
else:                RC(Y) = P · (1 + i)^(Y − y₀)

Replacement events: the first due year is Y_r = y₀ + L (or y_last_replacement + L). If Y_r < Y₀ the asset is overdue and is scheduled at Y₀ (flag overdue; optional tenant setting spreads the overdue backlog evenly over n years). After replacement the next event is Y_r + L, with RC re-based at the replacement year.

Maintenance:

M(Y) = m · RC(Y)                                   (not in a replacement year)
S(Y) = s · RC(Y)   if k set and (Y − y_base) mod k = 0 and Y is not a replacement year

where y_base is the purchase or last replacement year.

Annual budget:

B(Y) = Σ_assets [ M(Y) + S(Y) + RC(Y)·[Y is replacement year] ]

Recommended level annual reserve contribution (smallest constant yearly amount keeping the reserve non-negative across the horizon):

C = max( 0 ,  max_{n=1..H} ( Σ_{Y=Y₀}^{Y₀+n−1} B(Y) − R₀ ) / n )

Insurance adequacy (club-owned and optionally private assets):

ratio(Y₀) = V_current / RC(Y₀)
under-insured if ratio < 0.90 ;  over-insured if ratio > 1.20   (tenant-configurable)

6.3 Inclusion rules ​

  • Budget: ownership = 'club' and status IN ('active','in_repair').
  • Missing y₀ → excluded from depreciation; replacement based on V if present, flagged incomplete. Missing both P and V → excluded, reason forecast.excluded.no_value.
  • y₀ > Y₀ (future purchase) → included from y₀ onward.
  • Retired/sold/lost → excluded from future years.

6.4 Worked example ​

Trumpet, P = €1 200.00 (120 000 ¢), y₀ = 2016, L = 20, r = 0.10, m = 0.02, i = 0.03, no valuation, Y₀ = 2027.

QuantityComputationResult
Age 20272027 − 201611
Book value 2027120 000 − 108 000 · 11/2060 600 ¢ = €606.00
RC 2027120 000 · 1.03¹¹ = 166 108.06166 108 ¢ = €1 661.08
Maintenance 20270.02 · 166 108 = 3 322.163 322 ¢ = €33.22
Replacement year2016 + 202036
RC 2036120 000 · 1.03²⁰ = 216 733.35216 733 ¢ = €2 167.33

These values are the first rows of the forecasting engine's table-driven test suite.

6.5 Output shape ​

ts
type ForecastResult = {
  params: ResolvedParamsSummary;
  years: Array<{
    year: number;
    maintenanceCents: bigint;
    majorServiceCents: bigint;
    replacementCents: bigint;
    totalCents: bigint;
    byCategory: Record<CategoryId, bigint>;
  }>;
  recommendedAnnualContributionCents: bigint;
  assets: Array<{ assetId: string; events: ForecastEvent[]; flags: ('overdue'|'incomplete')[] }>;
  excluded: Array<{ assetId: string; reasonCode: string }>; // reasonCode is an i18n key
  insurance: Array<{ assetId: string; ratio: number; status: 'ok'|'under'|'over' }>;
};

7. Non-functional requirements ​

AreaTarget
Performancep95 page TTFB < 300 ms for tenants with ≤ 5 000 assets; forecast < 100 ms compute
Payload≤ 100 KB gzipped JS, ≤ 30 KB CSS on initial route
AvailabilitySingle VM + managed/self-hosted Postgres; daily backups, 35-day retention
SecurityOWASP ASVS L2 as guideline; CSP with nonces; HSTS; __Host- cookies
AccessibilityWCAG 2.2 AA
PrivacyGDPR/AVG; processor agreement (verwerkersovereenkomst) template for clubs
i18nnl, en; CI key parity

Last updated:

Released under the GNU AGPL-3.0-or-later.