18 · Engineering practices & delivery
Purpose: Define how the Rasd Forms monorepo is laid out, built, linted, tested, documented, released and kept secure, so every package in the spine (00 §3) ships with the same quality bar and adopters can trust the supply chain. Audience: Rasd core engineers and contributors; platform/DevOps engineers at UN/NGO organisations who audit or vendor the packages; host-app developers who want to run the same test utilities against their own forms.
TL;DR
- One pnpm 11 + Turborepo 2.10 monorepo:
packages/*(the 17@rasd/*packages),apps/*(docs, playground, Next and Expo examples, Storybook, later the phase-3 license dashboard),tooling/*(shared configs). TypeScript 6strict+isolatedDeclarations+verbatimModuleSyntaxeverywhere. - ESM-only packages built with tsdown (web/core) and react-native-builder-bob (native);
exportsmaps with areact-nativecondition; platform split by package, never by file extension. tsup is unmaintained (research/08). - Lint = ESLint 9 flat config; format = Biome 2.5. One formatter, one linter, no Prettier.
- Test pyramid: Vitest 4 + fast-check + fuzzer for
@rasd/core; storage conformance suite against memory / Dexie / SQLite; RTL / RNTL 14 withvitest-axe; Playwright projectschromium,chromium-ar-rtl,offline,pwa,perf; Maestro on Expo; Storybook 10 + Chromatic; sync chaos tests. Coverage gates: core ≥ 90 %, renderers ≥ 80 %. - CI on GitHub Actions: SHA-pinned actions, Node 22/24 matrix, remote Turbo cache; required checks cover lint, typecheck, unit, contract, size-limit, publint/attw, a11y, E2E smoke, API report, security.
- Releases via Changesets (fixed group for runtime packages) and npm Trusted Publishing (OIDC) with provenance;
canarydist-tag on every merge; CycloneDX SBOM on every GitHub Release. - Semver per package; RFD spec
MAJOR.MINORwith ≥ 12-month deprecations; support matrix React 19 (18.3 tested), RN ≥ 0.81 New Architecture, Expo ≥ 54, last-2 browsers, Android 7+. - Every PR satisfies the review checklist and Definition of Done (§12); ADRs in
docs/adr/, RFCs inrfcs/; DCO sign-off and SPDX headers, no CLA.
1. Repository layout
rasd-forms/
├─ pnpm-workspace.yaml # workspaces + catalog: + pnpm 11 security settings (§2.5)
├─ package.json # private root: scripts only
├─ turbo.jsonc # task graph (§2.6)
├─ tsconfig.base.json # shared compiler options (§2.2)
├─ biome.json eslint.config.js .size-limit.js lefthook.yml
├─ .changeset/ # config.json (fixed group) + pending changesets
├─ .github/
│ ├─ workflows/ ci.yml release.yml canary.yml nightly.yml codeql.yml scorecard.yml
│ ├─ ISSUE_TEMPLATE/ bug.yml feature.yml form-compat.yml security.md
│ └─ PULL_REQUEST_TEMPLATE.md CODEOWNERS dependabot.yml renovate.json5
├─ packages/ # one dir per @rasd/* package (spine §3)
│ ├─ core/ react/ builder/ element/ storage/ storage-dexie/ sync/ pwa/
│ │ license/ themes/ xlsform/ server/ cli/ testing/ → tsdown
│ ├─ native/ storage-sqlite/ → bob
│ └─ media/ → tsdown (web) + bob (native entry)
├─ apps/
│ ├─ docs/ Docusaurus 3.10 (en / ar RTL / fr), TypeDoc pages, live playground
│ ├─ playground-web/ Vite + vite-plugin-pwa; Playwright, Lighthouse CI, CSP target
│ ├─ example-next/ Next.js App Router + Serwist; SSR/RSC smoke target
│ ├─ example-expo/ Expo SDK ≥ 54; Maestro flows; Hermes bytecode size tracked
│ ├─ storybook/ Storybook 10 (react-vite + react-native-web-vite), one story set
│ └─ license-dashboard/ customer portal (phase 3, spine §3); private, never published
├─ tooling/ eslint-config/ tsconfig/ tsdown-config/ bob-config/ vitest-config/ scripts/
├─ docs/ this design set + schema/ examples/ research/ adr/
├─ rfcs/ 0000-template.md, accepted/
├─ fixtures/ forms (*.form.json), submissions, storage snapshots per released version
└─ LICENSE LICENSE-COMMERCIAL SECURITY.md CONTRIBUTING.md CODE_OF_CONDUCT.md GOVERNANCE.md
Each package owns src/, src/internal/ (never exported), tests beside sources, package.json, a build config, a .size-limit.js entry, api-extractor.json, generated CHANGELOG.md and a published README.md. Packages import each other only as @rasd/<name> (resolved through the rasd-source condition in dev), never by relative path. The private rasd-source condition is wired once per tool — TypeScript customConditions (§2.2), Vite/Vitest resolve.conditions, Metro unstable_conditionNames in the Expo example (via react-native-monorepo-config), Storybook's Vite config — so in-repo apps and tests run against src/ while consumers always get dist/ (research/08). Apache-2.0 vs FSL per package follows 00 §12.
2. Toolchain
2.1 Versions (pinned via catalog:; facts from research/08)
| Concern | Choice | Version / note |
|---|---|---|
| Package manager | pnpm | 11.x (Node ≥ 22); skip 11.0.8 (OIDC publish bug pnpm#11513) |
| Task runner | Turborepo | 2.10, remote cache |
| Language | TypeScript | 6.0; TS 7 native preview as an advisory CI job |
| Web/core build | tsdown (Rolldown) | unbundle: true, dts: { isolatedDeclarations: true }; not tsup (unmaintained) |
| Native build | react-native-builder-bob | ≥ 0.40 (ESM-only template); targets module, typescript, codegen |
| Unit/component | Vitest 4 + RTL + vitest-axe + fast-check (web); Jest + @react-native/jest-preset + RNTL 14 (RN) | RNTL 14: React ≥ 19, RN ≥ 0.78, async render |
| E2E | Playwright (web), Maestro (Expo) | Detox not used (Expo community-only) |
| Stories / visual | Storybook 10 + Chromatic | Chromatic free 5 000 snapshots/mo, TurboSnap; Loki fallback |
| Lint / format | ESLint 9 flat + typescript-eslint v8 / Biome 2.5 | §3.1 |
| Docs | Docusaurus 3.10 + TypeDoc 0.28 (typedoc-plugin-markdown) | en / ar / fr, future.v4 |
| API guard | API Extractor run --local | verify isolatedDeclarations support (rushstack#4877); fallback TypeDoc-JSON diff |
| Release | Changesets + npm Trusted Publishing (OIDC) | npm ≥ 11.5.1, Node ≥ 22.14 runner, id-token: write |
| Budgets / packaging / security | size-limit, publint, @arethetypeswrong/cli; Renovate, pnpm audit, Socket, CodeQL, zizmor, Scorecard, pnpm sbom | §9, §10 |
| Node engines | tooling >=22.13; @rasd/server >=20; runtime packages none | GitHub runners default Node 24 |
2.2 TypeScript base config
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"isolatedModules": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true,
"module": "esnext",
"moduleResolution": "bundler", // bob: node16/nodenext break platform resolution
"target": "es2022", // Hermes down-levels via Babel in bob
"lib": ["es2023"], // packages add "dom" explicitly; core/storage never do
"types": [], // TS 6 default; packages opt in ("react", "react-native")
"customConditions": ["rasd-source"], // native packages prepend "react-native"
"jsx": "react-jsx",
"declaration": true, "declarationMap": true, "sourceMap": true,
"skipLibCheck": true
}
}
@rasd/core and @rasd/storage compile without dom so any DOM global fails typecheck; the core suite also runs under Node and under Hermes in the Expo example with identical snapshots (03 §17).
2.3 Build outputs
- tsdown packages: per-file
dist/*.js(unbundle: true) for file-granular tree-shaking;.d.tsfrom isolated declarations withouttsc;platformbrowser(react, builder, element, storage-dexie, pwa),neutral(core, storage, sync, license, themes, xlsform, testing) ornode(server, cli). React Compiler runs at build time (babel-plugin-react-compiler,target: '19', no runtime dep); component suites run compiled and uncompiled. - bob packages (
native,storage-sqlite, native entry ofmedia):[["module", { "esm": true }], "typescript"]; extension-less specifiers, named exports only, noimport.meta, no top-levelawait, noreact-native/Libraries/*deep imports (removed in RN 0.87). @rasd/element: tsdown ESM entry plus Vite library modeiifeforrasd-forms.iife.js(bundles React) andrasd-forms.css; SRI hashes in release notes.src/ships in every tarball so source maps andrasd-sourcework for consumers debugging Hermes traces.
2.4 package.json contract
// packages/react/package.json (abridged)
{
"name": "@rasd/react",
"license": "FSL-1.1-Apache-2.0",
"type": "module",
"sideEffects": ["./dist/styles.css", "./dist/locales/*.js"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": { "rasd-source": "./src/index.ts", "types": "./dist/index.d.ts", "browser": "./dist/index.js", "default": "./dist/index.js" },
"./elements/*": { "rasd-source": "./src/elements/*/index.ts", "types": "./dist/elements/*/index.d.ts", "default": "./dist/elements/*/index.js" },
"./locales/*": { "types": "./dist/locales/*.d.ts", "default": "./dist/locales/*.js" },
"./styles.css": "./dist/styles.css",
"./unstable": { "types": "./dist/unstable.d.ts", "default": "./dist/unstable.js" },
"./package.json": "./package.json"
},
"files": ["dist", "src", "!**/__tests__", "LICENSE", "README.md"],
"dependencies": { "@rasd/core": "workspace:^" },
"peerDependencies": {
"react": "^18.3.0 || ^19.0.0", "react-dom": "^18.3.0 || ^19.0.0",
"@rasd/storage": "workspace:^", "@rasd/sync": "workspace:^", "@rasd/license": "workspace:^", "@rasd/media": "workspace:^"
},
"peerDependenciesMeta": { "@rasd/storage": { "optional": true }, "@rasd/sync": { "optional": true }, "@rasd/license": { "optional": true }, "@rasd/media": { "optional": true } },
"publishConfig": { "access": "public", "provenance": true },
"repository": { "type": "git", "url": "git+https://github.com/rasd-forms/rasd-forms.git", "directory": "packages/react" }
}
Native packages use the branch order rasd-source, types, react-native, default (publint: types first within a branch, default last; Metro matches by condition name). Peer ranges: react-native ">=0.81.0 <1.0.0"; optional expo ">=54", expo-sqlite, @op-engineering/op-sqlite, react-native-gesture-handler ^3, react-native-reanimated ^4 (native reorder UI only). ESM-only, no CJS build: dual publishing duplicates React context (bob's dual-package hazard) and Node 20.19+/22.12+ require(esm) covers Node consumers. sideEffects is false except CSS, locale catalogs (they register into the i18n runtime) and the @rasd/pwa worker asset. Adding an exports entry is additive; removing or renaming one is a major.
2.5 Workspace settings
# pnpm-workspace.yaml
packages: ["packages/*", "apps/*", "tooling/*"]
catalog:
react: ^19.2.0
react-dom: ^19.2.0
react-native: 0.85.0 # example app; CI matrix 0.81 / 0.85 / 0.87
expo: ~56.0.0
typescript: ~6.0.0
zod: ^4.0.0
dexie: ^4.4.0
vitest: ^4.0.0
minimumReleaseAge: 4320 # 3 days for third-party packages
minimumReleaseAgeExclude: ["@rasd/*"]
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds: { esbuild: true, "@swc/core": true, better-sqlite3: true }
trustPolicy: no-downgrade
2.6 Turborepo task graph
// turbo.jsonc
{
"$schema": "https://turborepo.dev/schema.json",
"globalDependencies": ["tsconfig.base.json", "biome.json", "eslint.config.js"],
"globalEnv": ["CI", "RASD_SITE_KEY", "CHROMATIC_PROJECT_TOKEN"],
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", "lib/**"], "inputs": ["src/**", "package.json", "tsdown.config.ts", "babel.config.js"] },
"typecheck": { "dependsOn": ["^build"], "outputs": [] },
"lint": { "outputs": [] },
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"test:contract": { "dependsOn": ["^build"], "outputs": [] },
"api-report": { "dependsOn": ["build"], "outputs": ["etc/*.api.md"] },
"size": { "dependsOn": ["build"], "outputs": [] },
"pack-check": { "dependsOn": ["build"], "outputs": [] }, // publint + attw on `npm pack`
"e2e": { "dependsOn": ["^build"], "outputs": ["playwright-report/**"], "cache": false },
"storybook:build": { "dependsOn": ["^build"], "outputs": ["storybook-static/**"] },
"docs:build": { "dependsOn": ["^build", "api-report"], "outputs": ["build/**"] },
"bench": { "dependsOn": ["build"], "cache": false }
}
}
3. Coding standards
3.1 Lint and format — decision
ESLint 9 flat config lints; Biome 2.5 formats and organises imports; Prettier is not used. Only ESLint carries eslint-plugin-react-hooks v6 (React-Compiler rules), jsx-a11y, react-native-a11y, import-x boundary rules and typescript-eslint's type-aware rules (no-floating-promises, no-misused-promises, switch-exhaustiveness-check) that this codebase depends on; Biome's coverage of those is partial (research/08 §9). Biome formats 10–20× faster than Prettier and its organizeImports replaces import sorting; ESLint stylistic rules are all off so no two tools fight. Pre-commit (lefthook): biome check --staged then eslint --cache on staged files; CI runs both on the tree.
Mandatory rules beyond recommended-type-checked: switch-exhaustiveness-check, no-floating-promises, no-misused-promises, consistent-type-imports, import-x/no-default-export (allowed only in apps/* route files and stories), import-x/no-restricted-paths encoding the dependency rules of 03 §3.1, import-x/no-cycle (+ madge --circular), no-restricted-globals (window, document, navigator, fetch, timers in @rasd/core/@rasd/storage), no-restricted-imports (react-native/Libraries/*, lodash, moment), all react-hooks/*, jsx-a11y/strict, react-native-a11y/all, header/header (SPDX), no-console outside apps/* and the logger, and a custom rule banning physical CSS properties in favour of logical ones.
3.2 Naming and file structure
| Thing | Convention | Example |
|---|---|---|
| Files | kebab-case; one exported concept per file; *.test.ts(x) and *.stories.tsx beside source | form-engine.ts, select-one.tsx |
| Types / classes | PascalCase, no I prefix | FormDefinition, StorageAdapter |
| Functions / vars | camelCase; factories createX, hooks useX, guards isX, converters toX/fromX | createSyncEngine, useField, isRepeatElement |
| Constants | UPPER_SNAKE only for true constants | MAX_EXPR_DEPTH |
| Element types, error codes | spine: snake_case types, RASD_* codes | select_multiple, RASD_SYNC_REJECTED |
| CSS | rasd-<Component>__<part>, --rasd-<group>-<key> | rasd-SelectOne__option, --rasd-color-primary |
| Events | lower-case verbs; DOM events rasd:<event> | conflict, rasd:finalize |
| Branches / commits | feat/…, fix/…; Conventional Commits | feat(sync): tus adaptive chunking |
Barrels: exactly one index.ts per package and per public sub-path; none inside src/internal/; barrels hold export { x } from './x.js' lines only. Named exports only (bob requirement, Metro-safe). Files > 400 lines or functions > 60 lines need a justifying comment or a split.
3.3 Errors and logging
- Throw
RasdError(code,message,details,cause) for anything a host can act on; codes are frozen constants from@rasd/core/errorslisted in 17 · API reference. Never throw strings; never swallow — handle, wrap withcause, or route toonError. Async APIs reject, sync APIs throw, UI never crashes (per-element error boundary → placeholder + oneonErrorreport). - Engines catch every rejection and route it to the host
onErrorhook on<RasdProvider>(03 §10, 17); nothing in@rasd/*leaves a promise unhandled (RN ≥ 0.82 turns unhandled rejections intoconsole.error, which would spam field devices). - Logging only through
createLogger({ level, sink, redact })from@rasd/core:consolesink atwarnin production,debugin dev; the redactor strips bearer tokens, tus URLs,bind.sensitivevalues and anything underdata. No telemetry.
4. TypeScript API design rules
// Branded ids: SubmissionId and AttachmentId cannot be mixed
export type SubmissionId = string & { readonly __brand: 'SubmissionId' };
export type AttachmentId = string & { readonly __brand: 'AttachmentId' };
export const asSubmissionId = (s: string): SubmissionId => s as SubmissionId;
// Discriminated union on `type`; `x:${string}` is the open tail
export type Element = TextElement | NumberElement | SelectOneElement | RepeatElement | /* … */ CustomElement;
export interface CustomElement extends ElementBase { type: `x:${string}`; props: Record<string, unknown> }
// Exhaustive switch with a compile-time guard
export function valueKind(el: Element): ValueKind {
switch (el.type) {
case 'text': case 'barcode': return 'string';
case 'number': case 'rating': case 'range': return 'number';
/* … every literal … */
default: return assertUnknownCustom(el); // 'unknown' for `x:*`, `never` for anything else
}
}
- Explicit return type on every export (
isolatedDeclarationsenforces it).interfacefor host-extensible shapes (ElementBase,StorageAdapter),typefor unions and brands. - Options objects, not positional booleans;
readonlyin signatures; defaults documented with@default. import typefor anything erased; renderers depend on adapter packages only as type-only optional peers (03 §3.1).- No enums (
as constunions), no namespaces, noanyin exported signatures (unknown+ zod at boundaries). - Public surface = per-package
index.ts+ spine §11, guarded by an API Extractor.api.mdunderetc/; a diff requires theapi-changelabel and a matching changeset level.@rasd/*/unstableis exempt;@internalsymbols are stripped from.d.ts. rasd types form.jsonoutput is snapshot-tested againsttoSubmission()for every fixture (FR-150).- Deprecations:
@deprecatedJSDoc + one-time dev-onlyconsole.warn+ changelog entry; removal ≥ 12 months later and only in a major.
5. Testing strategy
5.1 The pyramid
| Layer | Packages | Tool | Proves | Gate |
|---|---|---|---|---|
| Unit + property + fuzz | core, storage, sync, license, xlsform, themes, cli | Vitest 4, fast-check, grammar fuzzer | REL semantics, engine graph, validation, diff/migrate, token state machine | PR; core ≥ 90 % lines/branches |
| Contract | storage-*, sync transports, @rasd/server | runConformanceSuite() (09 §13); rspConformance({ baseUrl, getAuthToken }) (10 §11) run against @rasd/server and fakeTransport() | Every adapter behaves identically; the reference server and the fake agree | PR (memory, fake-indexeddb, better-sqlite3, server in Docker); nightly real browsers/emulator |
| Component | react, native, builder, element | RTL + vitest-axe (Vitest browser mode where needed), RNTL 14 | Rendering, a11y wiring, RTL, font scale, keyboard, registry overrides | PR; renderers ≥ 80 % |
| Visual | react, native (RN-web), builder | Storybook 10 + Chromatic (TurboSnap) | Locale × mode × scale matrix pixel-stable | PR (changed stories) |
| E2E web | playground-web, example-next, element | Playwright chromium, firefox, webkit, chromium-ar-rtl, offline, pwa, perf | Offline capture → sync, SW update safety, install, CSP, two-tab leader | PR smoke; nightly full |
| E2E native | example-expo | Maestro (Android emulator in CI; iOS nightly on macOS / EAS Workflows) | Same journeys on device, backup exclusion, low-RAM profile | nightly + release |
| Performance | core, react, native, storage | Vitest bench, Playwright perf (4× CPU throttle), pnpm bench:storage, Hermes bytecode size | §10 budgets | PR (bench, size); nightly device |
| Sync chaos | sync + storage + testing | fault-injecting transport, fake clock, seeded RNG | No loss/duplication under partitions, restarts, 5xx, quota, revocation | PR (10 seeds); nightly (100) |
| Security | all | CodeQL, pnpm audit, Socket, pollution corpus, CSP run | Supply chain and sandbox invariants | PR |
Flake policy: a test that fails then passes on retry is quarantined the same day (test.fixme + issue) and fixed within two weeks; CI retries: 1 for E2E, 0 for unit. Other runtime packages target ≥ 85 % coverage; the builder ≥ 70 % plus full story coverage.
5.2 Engine and REL: property-based and fuzz tests
import { expect } from 'vitest';
import { fc, test } from '@fast-check/vitest';
import { parseExpression, evaluate, printExpression } from '@rasd/core';
import { relArbitrary, evalContext } from './helpers/rel-arbitrary.js'; // grammar-driven REL generator (${ref}, calls, operators)
test.prop([relArbitrary({ depth: 6 })])('print∘parse is identity on the AST', (src) => {
const ast = parseExpression(src);
expect(parseExpression(printExpression(ast))).toEqual(ast);
});
test.prop([fc.integer(), fc.integer()])('+ matches JS on integers', (a, b) => {
expect(evaluate(parseExpression(`${a} + ${b}`), evalContext())).toBe(a + b);
});
test.prop([fc.integer({ min: 0, max: 20 }), fc.integer({ min: 0, max: 20 })])('${ref} arithmetic reads current values', (adults, children) => {
const ast = parseExpression('${adults} + ${children}');
expect(evaluate(ast, evalContext({ adults, children }))).toBe(adults + children);
});
Required suites: (a) parser round-trip and a precedence oracle against a reference recursive-descent parser (spine 00 §5 precedence table); (b) ODK compatibility corpus (20 §11) — every relevant/constraint/calculation from public XLSForm test forms parses or is flagged, hyphenated aliases (string-length(, selected-at(, count-selected(, indexed-repeat() resolve only in call position, results diffed against pyxform/JavaRosa outputs generated in CI; (c) sandbox invariants — grammar fuzzer (10 000 cases per PR, 100 000 nightly) plus the prototype-pollution corpus from 05, never exceeding ≤ 10 ms / ≤ 100 000 steps / ≤ 8 KiB source; (d) dependency graph — random relevant/calculate edges: incremental recompute equals brute-force, cycles detected at load; (e) diffDefinitions ⇄ migrateSubmission — migrated drafts validate against the new definition and loss equals the dropped keys; (f) definitionHash stable across key order and whitespace.
5.3 Storage contract tests
The conformance suite (@rasd/storage/conformance, ~180 cases, framework-agnostic — 09 §13) is the single definition of adapter behaviour:
import 'fake-indexeddb/auto';
import { runConformanceSuite } from '@rasd/storage/conformance';
import { createDexieStorage } from '@rasd/storage-dexie';
runConformanceSuite(() => createDexieStorage({ namespace: `t-${crypto.randomUUID()}` }), { platform: 'web' });
Matrix: memory (Node); dexie (Vitest + fake-indexeddb/auto on PR; Vitest browser mode on Chromium/Firefox/WebKit incl. private context nightly); sqlite (Vitest + @rasd/storage-sqlite/node = better-sqlite3, plaintext and field-level encryption; expo/op drivers run the same suite inside the Expo example on the emulator nightly). Extra suites: migration fixtures for every released storage version (fixtures/dexie-v<n>.json, fixtures/sqlite-v<n>.db) upgraded to HEAD, crash-mid-chunk migration, checksum tamper, downgrade refusal, wrong-key/AAD-transplant negatives, export→import equality, quota simulation, concurrent patch fuzz with expectedRev.
5.4 Component tests
Web: renderForm() from @rasd/testing (06 §18, 17 §14) with RTL + user-event; every element type is tested for label/description/error wiring (aria-describedby), keyboard operation, dir="rtl" layout, readonly/relevant behaviour, registry override, and vitest-axe zero violations in en, ar, en-XB. Native: RNTL 14 (render awaited), role + accessible-name queries only, accessibilityState asserted, RTL via useDirection() independent of I18nManager, font scale 2.0. Hosts on Jest get the same axe rule set through jest-axe via the @rasd/testing/axe glue. Autosave and debounce use fake timers; Dexie-backed component tests use fake-indexeddb. Custom x: elements registered through defineElement() are covered by a shared contract test (renders with props, honours readonly/relevant, exposes the accessible-props contract, and a throwing component is contained by the per-element boundary — 03 §17).
5.5 End-to-end
Playwright projects: chromium, firefox, webkit; chromium-ar-rtl (locale: 'ar', dir asserted); offline (shell loaded, context.setOffline(true), form filled, finalized, tab killed and resumed, then online sync against @rasd/server running as a Docker service); pwa (waiting → prompt → useRasdBusy() defers reload; navigator.storage.persisted(); install criteria; CSP report-to endpoint fails the run on any report); perf (4× CPU throttle, 3G profile). Two-tab leader election and BroadcastChannel propagation use two contexts. Maestro flows on example-expo: capture-offline.yaml, sync-resume.yaml (kill mid-tus upload), rtl.yaml, low-memory.yaml (1 GB profile), backup-exclusion.yaml (bmgr backupnow → reinstall → no DB).
5.6 Visual regression, a11y and i18n snapshots
One story set drives web and RN-web. Matrix per story: locale en · ar · en-XB · xx-LS × mode light · dark · highContrast × font scale 1.0 · 1.3 · 2.0 (13); Chromatic diffs changed stories only; the Storybook Vitest plugin runs vitest-axe per story. Pseudo-locales catch concatenation and truncation; RTL snapshots catch unmirrored icons and physical CSS.
5.7 Sync chaos tests
import { expect, test } from 'vitest';
import { createSyncEngine } from '@rasd/sync';
import { fakeStorage, fakeClock, fakeTransport, fixtures } from '@rasd/testing';
import { seedFinalizedSubmissions, runChaos } from './helpers/chaos.js'; // repo-local helpers
test('chaos seed 42: no loss, no duplication', async () => {
const clock = fakeClock('2026-08-15T10:00:00Z');
const storage = fakeStorage();
// fakeTransport = in-memory RSP server + fault injection (17 §14); `faultyNetwork()` is the lower-level fetch shim
const transport = fakeTransport({
forms: [fixtures.pdmGfd2026],
faults: { seed: 42, dropRate: 0.2, statuses: [500, 503, 429], latencyMs: [50, 3000], partitionEvery: 5 },
});
const allIds = await seedFinalizedSubmissions(storage, fixtures.pdmGfd2026, { submissions: 500, attachments: 120 });
const engine = createSyncEngine({ storage, getAuthToken: async () => 't', transport, clock: clock.now });
await runChaos({ engine, clock, ticks: 10_000, restarts: 20 }); // kill/restart the engine, advance the fake clock
const acceptedIds = transport.received.map((s) => s.id);
expect(new Set(acceptedIds)).toEqual(new Set(allIds)); // nothing lost
expect(acceptedIds).toHaveLength(allIds.length); // nothing duplicated (idempotency keys)
expect(await storage.submissions.count({ status: 'synced' })).toBe(allIds.length); // every local row reached `synced`
});
Assertions: exactly-once acceptance under drops/5xx/partitions (idempotency keys); backoff schedule matches 10 §4.5 with the seeded RNG; tus resumes from Upload-Offset after restart; rejected never retries and never re-enters queued; device_revoked halts network but not storage; quota errors pause without loss; X-Rasd-License refresh updates license state; single-flight syncNow(); leader hand-off between two tabs; status transitions never skip a state of 00 §6 (finalized → queued → sending → synced, sending → queued on retry, sending → rejected on 4xx). Failing seeds are committed as regression cases.
5.8 @rasd/testing for hosts
Hosts get the same tools (17 §14): renderForm() (web via RTL, platform: 'native' via RNTL; kill()/restart() simulate process death), fakeStorage(), fakeClock(), faultyNetwork(), fakeTransport(), fakeMedia(), fakeLicense(state), form fixtures (fixtures.minimal, fixtures.pdmGfd2026, fixtures.kitchenSink, fixtures.perf500, fixtures.repeats200), propertyEngine() and the @rasd/testing/axe glue; RFD assertions use validateFormDefinition(def) from @rasd/core (00 §11) — Apache-2.0, so agencies can test their own forms in Vitest/Jest without a browser (FR-153).
6. Storybook, docs site, examples
- Storybook 10 (
apps/storybook):@storybook/react-viteand@storybook/react-native-web-viteon the same major, stories underpackages/*/src/**/*.stories.tsx, decorators for locale/direction/theme/font-scale/license state; builder stories mount<FormBuilder>with fixture forms and adnd-adapterfake sensor for deterministic drags; the on-device@storybook/react-nativerunner is ad hoc, not in CI. - Docs (
apps/docs, Docusaurus 3.10,future.v4, i18nen+arRTL +fr, versioned per minor): guides, the spine, TypeDoc-generated API pages, a live playground (@docusaurus/theme-live-codeblock+<rasd-form>from the IIFE build), a compatibility matrix regenerated from the CI matrix JSON, and a security page linking SBOM/provenance. Docs build is a required check whendocs/**or the public API changes. Fumadocs/Starlight/Mintlify were rejected: none combines RTL multi-locale, live React demos and $0 (research/08 §8). - Example apps are living recipes and CI targets:
playground-web(Vite +vite-plugin-pwa1.3injectManifestwithregisterRasdRoutes, Lighthouse CI on a Moto G profile, CSP),example-next(App Router,next/dynamicrenderer, Serwist 9),example-expo(SDK ≥ 54,expo-sqliteSQLCipher, EAS Workflows Maestro). Each shows observability wiring (§12.7); none contains secrets — dev origins run in theevaluatinglicense state.
7. CI/CD
7.1 Pipeline
flowchart LR
PR[Pull request] --> L[lint + typecheck + biome]
PR --> U[unit + property + contract]
PR --> C[component + axe + RNTL]
PR --> B[build + publint + attw + size-limit + api-report]
PR --> E["E2E smoke: chromium + chromium-ar-rtl + offline + pwa"]
PR --> S[CodeQL + audit + Socket + zizmor]
L & U & C & B & E & S --> M{required checks green and 1 CODEOWNER review}
M --> MAIN[merge to main]
MAIN --> CAN["canary publish, dist-tag canary"]
MAIN --> VP["Changesets 'Version Packages' PR"]
VP -->|merged| REL["release.yml: OIDC publish + provenance + SBOM + GitHub Release"]
MAIN --> N["nightly: full E2E, Maestro, browsers, chaos x10, benches, TS7 preview"]
7.2 Workflow (abridged)
# .github/workflows/ci.yml
name: ci
on:
pull_request: {}
push:
branches: [main]
permissions:
contents: read # default for every job; widened per job only
concurrency:
group: "ci-${{ github.ref }}"
cancel-in-progress: true
env:
TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}"
TURBO_TEAM: rasd
DO_NOT_TRACK: "1"
jobs:
check:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@<full-sha> # every action SHA-pinned; Renovate bumps the pins
- uses: pnpm/action-setup@<full-sha>
- uses: actions/setup-node@<full-sha>
with:
node-version: "${{ matrix.node }}"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test test:contract build api-report size pack-check --affected
- uses: codecov/codecov-action@<full-sha>
native:
runs-on: ubuntu-24.04
steps:
# …checkout / pnpm / setup-node / install exactly as in `check` (Node 24)…
- run: pnpm --filter @rasd/native --filter @rasd/storage-sqlite test # Jest + RNTL 14
e2e-smoke:
runs-on: ubuntu-24.04
services:
rsp: # reference RSP server for the offline → sync journey
image: ghcr.io/rasd-forms/server:main
ports: ["8080:8080"]
steps:
# …setup as in `check`…
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm turbo run e2e --filter=playground-web -- --project=chromium --project=chromium-ar-rtl --project=offline --project=pwa
security:
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # CodeQL upload only
steps:
# …setup as in `check`…
- run: pnpm audit --audit-level=high
- uses: socket/socket-security-action@<full-sha>
- uses: zizmorcore/zizmor-action@<full-sha>
- uses: github/codeql-action/init@<full-sha>
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@<full-sha>
ts7-preview:
runs-on: ubuntu-24.04
continue-on-error: true # advisory until TS 7 is GA
steps:
# …setup as in `check`…
- run: pnpm dlx @typescript/native-preview -p tsconfig.json --noEmit
Caching: pnpm store keyed on pnpm-lock.yaml; Turborepo remote cache for build/test/typecheck; Playwright browsers cached by version. --affected limits PR work to changed packages plus dependents; main runs everything. Required checks (branch protection, linear history, signed commits): check (22), check (24), native, e2e-smoke, security, chromatic (UI PRs), docs-build (docs/API PRs). Targets: PR ≤ 12 min, nightly ≤ 90 min.
7.3 Release
- Changesets:
fixedgroup = core, react, native, builder, element, storage, storage-dexie, storage-sqlite, sync, pwa, media, license, themes, testing;xlsform,cli,serverindependent. Every PR touchingpackages/*needs a changeset (bot-enforced).changesets/actionopens "Version Packages"; merging it triggersrelease.yml. - release.yml:
environment: npm(2 required reviewers),permissions: { id-token: write, contents: write }, Node 24, npm ≥ 11.5.1,pnpm changeset publish(shells tonpm publish, avoiding the pnpm 11.0.8 OIDC bug); provenance attached automatically (public repo, exactrepository.url); each package registered on npmjs.com with one trusted publisher and "Require 2FA and disallow tokens". Post-publish:pnpm sbom(CycloneDX + SPDX) per package attached to the GitHub Release; clean-containernpm installsmoke; docs version cut. - Canary: every
mainmerge runschangeset version --snapshot canaryand publishes--tag canary(0.0.0-canary-<date>-<sha>) with provenance; canaries older than 30 days are deprecated. - Changelog:
@changesets/changelog-githubinto per-packageCHANGELOG.md; release notes grouped Breaking · Features · Fixes · RFD spec · Security; a "Migration" section is mandatory for majors and RFD MINOR additions. - Rollback:
npm deprecatethe bad version and publish a patch; never unpublish.
8. Versioning and compatibility policy
stateDiagram-v2
[*] --> stable
stable --> deprecated: RFC accepted, runtime warning and codemod shipped
deprecated --> removed: next MAJOR, at least 12 months later
removed --> [*]
| Axis | Scheme | Guarantee |
|---|---|---|
@rasd/* packages | semver; runtime packages in a fixed group | Patch never changes engine semantics; minor = additive API / RFD MINOR support; major = removed exports, changed defaults, dropped platforms; ≥ 12-month deprecation warnings |
RFD spec (rasd: "1.x") | MAJOR.MINOR | Ignore-and-preserve unknown properties within a major; MINOR = optional properties, new element types, new REL functions; MAJOR ships a converter that round-trips every fixture; JSON Schema $id per version (04) |
| Storage schema | integer per adapter | Upgrades from any release ≤ 12 months old (N-3 minors via fixtures); export works from any version |
| RSP / RLT | /v1; typ: "RLT" + kid | Additive fields only within v1; new keys ship in minors |
| React | ^18.3 || ^19 for @rasd/react (19 primary); ^19 for @rasd/native | 18.3 stays in the CI matrix until 12 months after React 20 GA |
| React Native / Expo | >=0.81 <1, New Architecture only; Expo ≥ 54 | Matrix RN 0.81 / 0.85 / 0.87, Expo 54 / 56; floors move only in a major with 6 months' notice |
| Browsers | last 2 Chrome/Edge/Firefox/Safari; Android WebView/Chrome ≥ 100; iOS home-screen web apps | Feature detection, never UA sniffing; missing features → degraded, never a crash |
| Node | tooling ≥ 22.13; @rasd/server ≥ 20 | Runtime packages carry no Node requirement |
Breaking-change protocol: RFC (§12.5) → deprecation release with runtime warning and codemod (rasd migrate <from> <to>) where feasible → removal in the next major no earlier than 12 months later; the docs compatibility page lists every deprecation with its removal date.
9. Security tooling
| Control | Tool / setting | Cadence |
|---|---|---|
| Dependency updates | Renovate (config:best-practices, grouped RN/Expo bumps, minimumReleaseAge: 3 days, weekly lockfile maintenance, automerge patch/minor devDeps) + Dependabot alerts | continuous |
| Install-time defence | pnpm 11 settings in §2.5; --frozen-lockfile everywhere | every install |
| Vulnerability scan | pnpm audit --audit-level=high (fails PR), Socket (typosquat / install-script / obfuscation), OpenSSF Scorecard ≥ 8 | PR + weekly |
| Static analysis | CodeQL (security-extended), semgrep pack for eval/new Function/innerHTML | PR |
| Secrets | GitHub secret scanning + push protection; gitleaks pre-commit; no .env committed | continuous |
| Actions hygiene | full-SHA pins, permissions: contents: read default, zizmor, OIDC-only publish, reviewer-gated environments, no pull_request_target | PR |
| SBOM / provenance | pnpm sbom CycloneDX + SPDX on Releases; npm provenance (SLSA build L2 on GitHub-hosted runners) | release |
| Disclosure | SECURITY.md: private reporting via GitHub Security Advisories, 72 h acknowledgement, 90-day coordinated disclosure, CVE for confirmed issues, backports to the last two minors | ongoing |
| Fuzz + sandbox | REL grammar fuzzer, pollution corpus, CSP-enforced playground run | PR / nightly |
| Vendor pack | securityReport(), MASVS/ASVS mapping, SBOM, provenance, CAIQ v4 self-assessment (16, research/11) | per minor |
10. Performance budgets
// .size-limit.js (min+gzip; React/RN externalised)
export default [
{ name: '@rasd/core', path: 'packages/core/dist/index.js', limit: '45 kB', import: '*' },
{ name: '@rasd/core REL only', path: 'packages/core/dist/index.js', limit: '10 kB', import: '{ parseExpression, evaluate }' },
{ name: '@rasd/react default renderer', path: 'packages/react/dist/index.js', limit: '90 kB', import: '{ RasdProvider, FormRenderer }', ignore: ['react', 'react-dom'] },
{ name: '@rasd/react styles', path: 'packages/react/dist/styles.css', limit: '12 kB' },
{ name: 'form-runner (core+react+dexie+sync)', path: ['packages/react/dist/index.js', 'packages/storage-dexie/dist/index.js', 'packages/sync/dist/index.js'], limit: '120 kB', ignore: ['react', 'react-dom'] },
{ name: '@rasd/builder dnd-adapter', path: 'packages/builder/dist/internal/dnd-adapter/index.js', limit: '25 kB', ignore: ['react', 'react-dom'] },
{ name: '@rasd/element IIFE (incl. React)', path: 'packages/element/dist/rasd-forms.iife.js', limit: '175 kB' },
{ name: '@rasd/storage', path: 'packages/storage/dist/index.js', limit: '8 kB' },
{ name: '@rasd/storage-dexie (own code)', path: 'packages/storage-dexie/dist/index.js', limit: '12 kB', ignore: ['dexie'] },
{ name: '@rasd/storage-sqlite', path: 'packages/storage-sqlite/lib/module/index.js', limit: '15 kB', ignore: ['expo-sqlite', '@op-engineering/op-sqlite'] },
{ name: '@rasd/license', path: 'packages/license/dist/index.js', limit: '8 kB' }
];
Budgets are the spine's numbers (00 §12) plus the storage figures from 09 §14; raising any limit requires an RFC-lite note in the PR and a changeset entry, never a silent config edit.
Runtime budgets (02 NFR-001…008, 06 §10, 07 §6, 09 §14): keystroke → paint ≤ 16 ms mid-range / ≤ 50 ms low-end reference on a 500-question form; page switch ≤ 100 ms; form open ≤ 1.5 s for 300 elements; add repeat row 200 ≤ 50 ms; dataset search over 50 000 rows ≤ 150 ms after debounce; autosave patch p95 ≤ 50 ms on device (NFR-006; the adapter-level targets in 09 §14 are tighter: ≤ 20 ms Dexie on a Moto G, ≤ 15 ms SQLite); heap ≤ 150 MB with 30 thumbnails; every chunk < 2 MB uncompressed (Workbox precache default, research/05). Enforcement: size-limit on PR (comment + fail); Vitest bench on engine hot paths with a 15 % regression threshold against the main baseline; Playwright perf on PR; nightly device runs (Moto G-class emulator, iPhone SE simulator) publish to the compatibility page and a > 20 % regression opens an issue; example-expo Hermes bytecode size tracked per release.
11. Accessibility and i18n gates
- A11y (PR-blocking):
vitest-axe0 violations for every story inen,ar,en-XBat font scale 2.0;@axe-core/playwrighton example apps;jsx-a11y/strictandreact-native-a11y/all; keyboard-only Playwright test per element type; every builder drag has a tested non-drag equivalent (WCAG 2.2 SC 2.5.7, research/03); touch targets ≥ 48 px asserted from layout; focus ring 2 px / 3:1 checked byrasd theme check. - i18n (PR-blocking):
rasd i18n check— all library UI strings from the catalog, ICU subset only, keys complete inen/ar/fr, no JSX string concatenation, pseudo-locale build passes, RTL snapshots stable, no physical CSS,Intlonly via runtime helpers (13). - Manual (per minor): screen-reader matrix (NVDA + Chrome, VoiceOver iOS/macOS, TalkBack) on the reference form; native Arabic review of new strings.
12. Process
12.1 Code review checklist
- One concern per PR; description states why, links issue/RFC, lists behaviour and RFD/API impact.
- Spine conformance: names, shapes, codes, status values match 00; no new vocabulary without a spine update.
- Types: explicit return types, no
any, exhaustive switches, branded ids at boundaries;.api.mddiff reviewed and semver-labelled. - Offline-first: no network await on the render path; storage writes transactional; failures surface as
RasdErrorcodes. - Data safety: no path drops draft data; migrations reversible or export-safe;
bind.sensitiverespected in logs. - Tests: unit for logic, contract for adapters, story + axe for UI, regression test for every bug fix, chaos case if sync touched.
- Bundle:
size-limitunchanged or justified; heavy code lazy; new dependency reviewed under thedepslabel (license, size, maintenance, install scripts). - i18n/RTL/a11y gates green; strings in catalog; logical CSS.
- Security: no
eval/innerHTML, inputs validated at boundaries, secrets never logged; threat-model note for new surfaces. - Docs: JSDoc on public symbols, docs page/recipe updated, changeset present at the right level.
12.2 Definition of Done
Merged with the checklist satisfied; coverage at or above the package gate; story in the locale × mode × scale matrix; E2E or Maestro flow if a user journey from 01 is touched; docs and example app updated; size and perf budgets green; a11y/i18n gates green; changeset written; ADR recorded for architectural choices; unstable paths promoted or removed.
12.3 Contribution guide
CONTRIBUTING.md covers prerequisites (Node 22.13+, pnpm 11, JDK 17 + Android SDK for native), pnpm i && pnpm turbo build, pnpm dev (playground + Storybook), running each test layer locally, Conventional Commits + DCO, PR flow (draft → checks → CODEOWNER review → squash-merge), good first issue labelling, translation PRs via the ar/fr catalog template, and the rule that changes to docs/00 need two maintainers.
12.4 Issue templates
bug.yml (package, version, platform, minimal RFD or playground link, expected/actual, redacted logs), feature.yml (problem, proposed API, spine impact, alternatives), form-compat.yml (XLSForm/Kobo/ODK form that misbehaves, with expected ODK behaviour), security.md (redirects to private advisories). Triage SLA: labelled within 2 business days; severity:data-loss jumps the queue and gets a patch release.
12.5 RFC and ADR process
RFC (rfcs/NNNN-title.md) for anything changing the RFD spec, REL, RSP, RLT, storage schema, public API shape or support matrix: summary, motivation, detailed design, drawbacks, alternatives, migration, unresolved questions; ≥ 7-day comment window; accepted by two maintainers; linked from the implementing changeset. ADR (docs/adr/ADR-NNN-title.md, MADR format; initial set = 03 §16) for architectural decisions; ADRs are immutable once accepted — supersede, never edit.
12.6 Licensing headers, DCO, CLA
Every source file starts with // SPDX-License-Identifier: Apache-2.0 (core packages) or // SPDX-License-Identifier: FSL-1.1-Apache-2.0 (gated packages), enforced by eslint-plugin-header per package; root LICENSE + LICENSE-COMMERCIAL, per-package LICENSE copied at build. DCO sign-off (git commit -s) is required, no CLA — a CLA deters agency contributors and FSL already provides the Apache conversion. license-checker-rseidelsohn runs in release CI with an allow-list (MIT, Apache-2.0, BSD-2/3, ISC, 0BSD, OFL for fonts); copyleft dependencies are rejected in runtime packages.
12.7 Observability in examples
The library ships no telemetry; the examples show how a host wires the provider's onError / logger hooks to Sentry through createSentryScrubber() (16 §12), renders useSync() status (last sync time, pending counts) and storage.estimate() in a diagnostics panel, logs at warn through the redacting logger, and tags errors with formId, formVersion, definitionHash, @rasd/* version and license state. Web Vitals + Lighthouse CI run on the playground; the Expo example wires crash reporting behind an opt-in flag.
13. Failure modes of the delivery pipeline
| Failure | Detection | Response |
|---|---|---|
| Compromised or typosquatted dependency (Shai-Hulud-class worm, install script) | Socket + pnpm audit on PR; minimumReleaseAge holds new versions ≥ 3 days; allowBuilds blocks unknown install scripts; Renovate diff | PR blocked; lockfile pinned; incident note in SECURITY.md history; if a published @rasd/* version pulled a bad transitive, npm deprecate + patch release within 24 h |
Publish token or OIDC misconfiguration (pnpm 11.0.8 bug, wrong repository.url, missing id-token: write) | release.yml fails at changeset publish; provenance check in the post-publish smoke (npm view --json dist.attestations) | Fix the workflow, re-run; never fall back to a long-lived npm token; a version that reached npm without provenance is deprecated and re-released as a patch |
| Partial publish of the fixed group (some packages at N, others at N-1) | post-publish npm install smoke in a clean container resolves the whole group at one version | Re-run publish (idempotent per package); until green, canary tag is left untouched and the release notes are held |
| Flaky test masks a real regression | flake quarantine rule (§5.1), retry budget 1 for E2E / 0 for unit, seeded RNG everywhere | test.fixme + issue same day, fix ≤ 2 weeks; a quarantined test cannot stay quarantined across a release |
| Turborepo remote-cache poisoning or stale hash (input not declared) | globalDependencies/inputs reviewed in PR; nightly --force run compares with cached results | Cache-key rotation (TURBO_TEAM namespace bump); missing inputs added to turbo.jsonc; the nightly diff fails the build |
| Size or perf budget regression slips through (new dependency, lost tree-shaking) | size-limit PR comment + fail; sideEffects and publint checks; Vitest bench 15 % threshold | Lazy-load or replace the dependency; budgets are never raised in the same PR that breaks them (§10) |
| Public API changed without review | API Extractor .api.md diff without the api-change label; changeset level lower than the diff | CI fails; reviewer applies label + changeset; unstable paths are exempt by design |
| Storage schema migration breaks an older on-device database | migration fixtures per released version (§5.3) upgraded to HEAD on every PR; downgrade refusal test | PR blocked; a shipped bad migration gets a patch that repairs forward — never a downgrade — and export() must keep working throughout (09 §6) |
| Chromatic / third-party CI service outage | required check reports neutral after 30 min timeout | UI PRs wait or a maintainer runs loki locally and attaches the diff; never bypass the check by removing it from branch protection |
| Secret leaked into a log, fixture or story | secret scanning + push protection; gitleaks pre-commit; redacting logger tests (grep over 10 k fault-injection log lines) | Rotate immediately, purge from history, advisory if it touched a customer secret |
| Docs and code drift (API page shows removed prop) | docs:build depends on api-report; TypeDoc build fails on missing symbols; link checker | Docs PR blocked; the compatibility page is regenerated from CI JSON, not edited by hand |
14. Acceptance criteria
- Fresh clone:
pnpm i --frozen-lockfile && pnpm turbo build testpasses on Node 22 and 24 in ≤ 15 min cold, ≤ 3 min warm. - Every package passes
publintandattw --pack;exportsorder verified (typesfirst in branch,defaultlast,react-nativepresent for native packages). -
size-limitbudgets in §10 pass; builder and heavy elements are separate chunks. - Coverage gates hold: core ≥ 90 %, renderers ≥ 80 %, other runtime packages ≥ 85 %.
- Storage conformance passes for memory, dexie (fake + real browsers) and sqlite (better-sqlite3 + expo/op on emulator).
- Sync chaos suite passes 10 seeds on PR and 100 nightly with zero loss/duplication.
- Playwright
chromium,chromium-ar-rtl,offline,pwapass on PR;firefox,webkit,perfand Maestro flows nightly. - Every story is axe-clean across the locale × mode × scale matrix; Chromatic has no unapproved diffs.
- A release yields provenance-attested packages, SBOMs, generated changelogs and a docs version; canary publishes on every
mainmerge. - Renovate, CodeQL, secret scanning, Scorecard and zizmor active; all Actions SHA-pinned;
SECURITY.mdpublished. -
.api.mdreports exist for every package with a public surface and CI fails on undeclared changes. - Every "PR blocked" row of §13 has been exercised by a deliberately failing drill PR (budget breach, undeclared API change, unsigned action, broken migration fixture) at least once per quarter, and the release runbook covers the OIDC and partial-publish rows.
- Code samples in this document (
package.json,tsconfig.base.json,turbo.jsonc,.size-limit.js,ci.yml) are kept intooling/as the real files and this document links to them; drift failsdocs:build.
Open questions
- Keep the
^18.3 || ^19peer range for@rasd/reactat launch, or declare^19only (RNTL 14 and the React Compiler target push toward 19) and treat 18.3 as best-effort? - If API Extractor's
isolatedDeclarationsincompatibility persists, standardise on a TypeDoc-JSON diff for the API report? - Chromatic's free tier will be exceeded by the full 4 × 3 × 3 story matrix — run it nightly only, or self-host Loki?
- Turborepo remote cache: Vercel-hosted (simplest) or self-hosted (supply-chain optics for UN reviewers)?
- Do gated (FSL) packages stay on the public npm scope with provenance (recommended) or move to a private registry if the builder ever becomes source-closed?
- Nightly device benchmarks on a device farm (Firebase Test Lab / AWS Device Farm) rather than emulators, given the 2 GB-RAM Android target?
Related documents
00 · Decisions & conventions · 02 · Requirements · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 06 · Renderer React · 07 · Renderer native · 08 · Builder · 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding · 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 19 · Roadmap & work breakdown · 20 · Interoperability · 21 · Getting started · JSON Schema: form · JSON Schema: theme · Research: 08 · Library engineering, 03 · Drag-and-drop builder, 05 · PWA & embedding, 11 · Security threat model, 12 · Versioning & migration