Skip to content

Commit e19349b

Browse files
committed
Define Workspace as runtime-only context without persisted tool payloads - PR_26152_078-workspace-runtime-only-contract
1 parent 82ccd32 commit e19349b

3 files changed

Lines changed: 609 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Workspace Runtime-Only Contract Validation
2+
3+
PR: PR_26152_078-workspace-runtime-only-contract
4+
Date: 2026-06-02
5+
6+
## Scope
7+
8+
- Added `src/shared/contracts/workspaceRuntimeContract.js`.
9+
- Added `tests/shared/WorkspaceRuntimeContract.test.mjs`.
10+
- Updated required reports only.
11+
- No database implementation, authentication implementation, runtime UI, page, CSS, or HTML changes were made.
12+
13+
## Contract Summary
14+
15+
- Workspace is runtime-only.
16+
- Workspace does not persist tool payloads.
17+
- Workspace does not own saved tool state.
18+
- Workspace does not duplicate project or tool state storage.
19+
- Workspace may track runtime references and UI flow state:
20+
- `activeProjectId`
21+
- `activeToolId`
22+
- `activeToolStateId`
23+
- `dirty`
24+
- `recoveryAvailable`
25+
- `recoveryToolStateId`
26+
- `activePaletteContext`
27+
- `flowState`
28+
29+
## Data Ownership
30+
31+
- Project = persisted DB container.
32+
- Tool State = persisted DB record for one tool.
33+
- Workspace = current runtime session/UI context.
34+
- Manifest = portable export/import format.
35+
36+
## Validation Commands
37+
38+
Targeted contract tests:
39+
40+
```powershell
41+
$contractTests = @(
42+
'tests/shared/WorkspaceRuntimeContract.test.mjs',
43+
'tests/shared/ToolStateContract.test.mjs',
44+
'tests/shared/ProjectContract.test.mjs',
45+
'tests/shared/IdentityPermissionsContract.test.mjs'
46+
)
47+
$toolContractTests = Get-ChildItem -Path tests/shared/tools -Filter '*.test.mjs' | Sort-Object Name | ForEach-Object { $_.FullName }
48+
node ./scripts/run-node-test-files.mjs $contractTests $toolContractTests
49+
```
50+
51+
Result: PASS, `40/40 targeted node test file(s) passed`.
52+
53+
Static checks:
54+
55+
```powershell
56+
git diff --check -- src/shared/contracts tests/shared docs/dev/reports/workspace_runtime_only_contract_validation.md docs/dev/commit_comment.txt
57+
git diff --name-only -- '*.css' '*.html'
58+
git diff --name-only
59+
```
60+
61+
Results:
62+
63+
- PASS: Workspace runtime-only contract tests prove workspace cannot persist tool payload data.
64+
- PASS: Workspace runtime-only contract tests prove workspace cannot create saved tool state records.
65+
- PASS: Workspace runtime-only contract tests prove workspace can reference active project/tool/toolState IDs.
66+
- PASS: Workspace runtime-only contract tests prove workspace can track dirty/recovery runtime status.
67+
- PASS: Workspace runtime-only contract tests prove workspace recovery points to tool state recovery, not workspace-owned saved data.
68+
- PASS: Tool State contract tests confirm Tool State remains the saved editing source.
69+
- PASS: Project, Identity/Permissions, and Tool contract tests remain compatible.
70+
- PASS: no CSS or HTML files changed.
71+
- PASS: no whitespace errors from `git diff --check`.
72+
73+
## Skipped
74+
75+
- Repo-wide tests were not run.
76+
- Samples tests were not run.
77+
- Runtime/UI validation was not run because this PR only changes contracts, contract tests, and reports.
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
06/02/2026
5+
workspaceRuntimeContract.js
6+
*/
7+
8+
export const WORKSPACE_RUNTIME_CONTRACT_ID = "gamefoundrystudio.workspace.runtime-only";
9+
export const WORKSPACE_RUNTIME_CONTRACT_VERSION = "1.0.0";
10+
11+
export const WORKSPACE_RUNTIME_FIELDS = Object.freeze({
12+
ACTIVE_PROJECT_ID: "activeProjectId",
13+
ACTIVE_TOOL_ID: "activeToolId",
14+
ACTIVE_TOOL_STATE_ID: "activeToolStateId",
15+
DIRTY: "dirty",
16+
RECOVERY_AVAILABLE: "recoveryAvailable",
17+
RECOVERY_TOOL_STATE_ID: "recoveryToolStateId",
18+
ACTIVE_PALETTE_CONTEXT: "activePaletteContext",
19+
FLOW_STATE: "flowState",
20+
});
21+
22+
export const WORKSPACE_RUNTIME_FIELD_LIST = Object.freeze([
23+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_PROJECT_ID,
24+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_ID,
25+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_STATE_ID,
26+
WORKSPACE_RUNTIME_FIELDS.DIRTY,
27+
WORKSPACE_RUNTIME_FIELDS.RECOVERY_AVAILABLE,
28+
WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID,
29+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT,
30+
WORKSPACE_RUNTIME_FIELDS.FLOW_STATE,
31+
]);
32+
33+
export const WORKSPACE_RUNTIME_FLOW_STATES = Object.freeze({
34+
IDLE: "idle",
35+
OPENING: "opening",
36+
OPEN: "open",
37+
CLOSING: "closing",
38+
SAVING: "saving",
39+
});
40+
41+
export const WORKSPACE_RUNTIME_FLOW_STATE_LIST = Object.freeze([
42+
WORKSPACE_RUNTIME_FLOW_STATES.IDLE,
43+
WORKSPACE_RUNTIME_FLOW_STATES.OPENING,
44+
WORKSPACE_RUNTIME_FLOW_STATES.OPEN,
45+
WORKSPACE_RUNTIME_FLOW_STATES.CLOSING,
46+
WORKSPACE_RUNTIME_FLOW_STATES.SAVING,
47+
]);
48+
49+
export const WORKSPACE_RUNTIME_RULES = Object.freeze({
50+
RUNTIME_ONLY: true,
51+
DOES_NOT_PERSIST_TOOL_PAYLOADS: true,
52+
DOES_NOT_OWN_SAVED_TOOL_STATE: true,
53+
DOES_NOT_DUPLICATE_PROJECT_STORAGE: true,
54+
DOES_NOT_DUPLICATE_TOOL_STATE_STORAGE: true,
55+
RECOVERY_POINTS_TO_TOOL_STATE: true,
56+
});
57+
58+
export const WORKSPACE_DATA_OWNERSHIP = Object.freeze({
59+
PROJECT: "persisted-db-container",
60+
TOOL_STATE: "persisted-db-record-for-one-tool",
61+
WORKSPACE: "runtime-session-ui-context",
62+
MANIFEST: "portable-export-import-format",
63+
});
64+
65+
export const WORKSPACE_SAVED_EDITING_SOURCE = "tool-state";
66+
67+
export const WORKSPACE_RUNTIME_FORBIDDEN_FIELDS = Object.freeze([
68+
"payload",
69+
"payloadJson",
70+
"toolPayload",
71+
"toolPayloads",
72+
"savedToolState",
73+
"savedToolStates",
74+
"toolState",
75+
"toolStates",
76+
"toolStateRecords",
77+
"project",
78+
"projects",
79+
"projectRecords",
80+
"manifest",
81+
"databaseId",
82+
"recoveryState",
83+
"savedState",
84+
"swatches",
85+
"colors",
86+
"palettePayload",
87+
]);
88+
89+
export const WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS = Object.freeze({
90+
PALETTE_ID: "paletteId",
91+
SOURCE_TOOL_ID: "sourceToolId",
92+
SOURCE_TOOL_STATE_ID: "sourceToolStateId",
93+
VERSION: "version",
94+
});
95+
96+
export const WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELD_LIST = Object.freeze([
97+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.PALETTE_ID,
98+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_ID,
99+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_STATE_ID,
100+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.VERSION,
101+
]);
102+
103+
export const WORKSPACE_RUNTIME_ERRORS = Object.freeze({
104+
CONTEXT_INVALID: "WORKSPACE_RUNTIME_CONTEXT_INVALID",
105+
FIELD_NOT_ALLOWED: "WORKSPACE_RUNTIME_FIELD_NOT_ALLOWED",
106+
REFERENCE_INVALID: "WORKSPACE_RUNTIME_REFERENCE_INVALID",
107+
DIRTY_INVALID: "WORKSPACE_RUNTIME_DIRTY_INVALID",
108+
RECOVERY_AVAILABLE_INVALID: "WORKSPACE_RUNTIME_RECOVERY_AVAILABLE_INVALID",
109+
RECOVERY_TOOL_STATE_REQUIRED: "WORKSPACE_RUNTIME_RECOVERY_TOOL_STATE_REQUIRED",
110+
FLOW_STATE_INVALID: "WORKSPACE_RUNTIME_FLOW_STATE_INVALID",
111+
ACTIVE_PALETTE_CONTEXT_INVALID: "WORKSPACE_RUNTIME_ACTIVE_PALETTE_CONTEXT_INVALID",
112+
});
113+
114+
export function isWorkspaceRuntimeFlowState(value) {
115+
return WORKSPACE_RUNTIME_FLOW_STATE_LIST.includes(value);
116+
}
117+
118+
export function isWorkspaceRuntimeReference(value) {
119+
return value === undefined || value === null || hasNonEmptyString(value);
120+
}
121+
122+
export function canWorkspacePersistToolPayloadData() {
123+
return false;
124+
}
125+
126+
export function canWorkspaceCreateSavedToolStateRecord() {
127+
return false;
128+
}
129+
130+
export function isToolStateSavedEditingSource(value) {
131+
return value === WORKSPACE_SAVED_EDITING_SOURCE;
132+
}
133+
134+
export function workspaceRecoveryTargetsToolState(workspaceContext) {
135+
if (!workspaceContext?.recoveryAvailable) {
136+
return true;
137+
}
138+
139+
return hasNonEmptyString(workspaceContext.recoveryToolStateId)
140+
&& !hasForbiddenWorkspaceField(workspaceContext);
141+
}
142+
143+
export function validateWorkspaceRuntimeContract(workspaceContext = {}) {
144+
const errors = [];
145+
146+
if (!workspaceContext || typeof workspaceContext !== "object" || Array.isArray(workspaceContext)) {
147+
errors.push(createContractError(
148+
WORKSPACE_RUNTIME_ERRORS.CONTEXT_INVALID,
149+
"Workspace runtime context must be an object.",
150+
"workspace"
151+
));
152+
153+
return Object.freeze({
154+
valid: false,
155+
errors: Object.freeze(errors),
156+
});
157+
}
158+
159+
collectForbiddenFieldErrors(workspaceContext, errors);
160+
validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_PROJECT_ID, errors);
161+
validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_ID, errors);
162+
validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.ACTIVE_TOOL_STATE_ID, errors);
163+
validateRuntimeReference(workspaceContext, WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID, errors);
164+
165+
if (workspaceContext.dirty !== undefined && typeof workspaceContext.dirty !== "boolean") {
166+
errors.push(createContractError(
167+
WORKSPACE_RUNTIME_ERRORS.DIRTY_INVALID,
168+
"Workspace dirty status must be a runtime boolean when provided.",
169+
WORKSPACE_RUNTIME_FIELDS.DIRTY
170+
));
171+
}
172+
173+
if (workspaceContext.recoveryAvailable !== undefined && typeof workspaceContext.recoveryAvailable !== "boolean") {
174+
errors.push(createContractError(
175+
WORKSPACE_RUNTIME_ERRORS.RECOVERY_AVAILABLE_INVALID,
176+
"Workspace recovery availability must be a runtime boolean when provided.",
177+
WORKSPACE_RUNTIME_FIELDS.RECOVERY_AVAILABLE
178+
));
179+
}
180+
181+
if (workspaceContext.recoveryAvailable === true && !hasNonEmptyString(workspaceContext.recoveryToolStateId)) {
182+
errors.push(createContractError(
183+
WORKSPACE_RUNTIME_ERRORS.RECOVERY_TOOL_STATE_REQUIRED,
184+
"Workspace recovery must point to a tool state recovery reference.",
185+
WORKSPACE_RUNTIME_FIELDS.RECOVERY_TOOL_STATE_ID
186+
));
187+
}
188+
189+
if (workspaceContext.flowState !== undefined && !isWorkspaceRuntimeFlowState(workspaceContext.flowState)) {
190+
errors.push(createContractError(
191+
WORKSPACE_RUNTIME_ERRORS.FLOW_STATE_INVALID,
192+
"Workspace flow state must be an allowed runtime flow state.",
193+
WORKSPACE_RUNTIME_FIELDS.FLOW_STATE
194+
));
195+
}
196+
197+
validateActivePaletteContext(workspaceContext.activePaletteContext, errors);
198+
199+
return Object.freeze({
200+
valid: errors.length === 0,
201+
errors: Object.freeze(errors),
202+
});
203+
}
204+
205+
export function getWorkspaceRuntimeReferences(workspaceContext = {}) {
206+
return Object.freeze({
207+
activeProjectId: normalizeReference(workspaceContext.activeProjectId),
208+
activeToolId: normalizeReference(workspaceContext.activeToolId),
209+
activeToolStateId: normalizeReference(workspaceContext.activeToolStateId),
210+
recoveryToolStateId: normalizeReference(workspaceContext.recoveryToolStateId),
211+
});
212+
}
213+
214+
function validateRuntimeReference(workspaceContext, fieldName, errors) {
215+
if (!isWorkspaceRuntimeReference(workspaceContext[fieldName])) {
216+
errors.push(createContractError(
217+
WORKSPACE_RUNTIME_ERRORS.REFERENCE_INVALID,
218+
"Workspace runtime references must be non-empty strings when provided.",
219+
fieldName
220+
));
221+
}
222+
}
223+
224+
function validateActivePaletteContext(activePaletteContext, errors) {
225+
if (activePaletteContext === undefined || activePaletteContext === null) {
226+
return;
227+
}
228+
229+
if (typeof activePaletteContext !== "object" || Array.isArray(activePaletteContext)) {
230+
errors.push(createContractError(
231+
WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID,
232+
"Workspace active palette context must be an object when provided.",
233+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT
234+
));
235+
return;
236+
}
237+
238+
collectForbiddenFieldErrors(
239+
activePaletteContext,
240+
errors,
241+
WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT
242+
);
243+
244+
for (const fieldName of [
245+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.PALETTE_ID,
246+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_ID,
247+
WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.SOURCE_TOOL_STATE_ID,
248+
]) {
249+
if (!isWorkspaceRuntimeReference(activePaletteContext[fieldName])) {
250+
errors.push(createContractError(
251+
WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID,
252+
"Workspace active palette context references must be non-empty strings when provided.",
253+
`${WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT}.${fieldName}`
254+
));
255+
}
256+
}
257+
258+
if (activePaletteContext.version !== undefined && !isPositiveInteger(activePaletteContext.version)) {
259+
errors.push(createContractError(
260+
WORKSPACE_RUNTIME_ERRORS.ACTIVE_PALETTE_CONTEXT_INVALID,
261+
"Workspace active palette context version must be a positive integer when provided.",
262+
`${WORKSPACE_RUNTIME_FIELDS.ACTIVE_PALETTE_CONTEXT}.${WORKSPACE_ACTIVE_PALETTE_CONTEXT_FIELDS.VERSION}`
263+
));
264+
}
265+
}
266+
267+
function collectForbiddenFieldErrors(record, errors, pathPrefix = "") {
268+
for (const fieldName of WORKSPACE_RUNTIME_FORBIDDEN_FIELDS) {
269+
if (Object.hasOwn(record, fieldName)) {
270+
errors.push(createContractError(
271+
WORKSPACE_RUNTIME_ERRORS.FIELD_NOT_ALLOWED,
272+
"Workspace runtime context must not persist project, tool state, manifest, or tool payload data.",
273+
pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
274+
));
275+
}
276+
}
277+
}
278+
279+
function hasForbiddenWorkspaceField(record) {
280+
if (!record || typeof record !== "object" || Array.isArray(record)) {
281+
return false;
282+
}
283+
284+
return WORKSPACE_RUNTIME_FORBIDDEN_FIELDS.some((fieldName) => Object.hasOwn(record, fieldName));
285+
}
286+
287+
function hasNonEmptyString(value) {
288+
return typeof value === "string" && value.trim().length > 0;
289+
}
290+
291+
function isPositiveInteger(value) {
292+
return Number.isInteger(value) && value >= 1;
293+
}
294+
295+
function normalizeReference(value) {
296+
return hasNonEmptyString(value) ? value : null;
297+
}
298+
299+
function createContractError(code, message, path) {
300+
return Object.freeze({ code, message, path });
301+
}

0 commit comments

Comments
 (0)