Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"socket.io": "^4.8.3",
"tsx": "^4.23.15",
"web-push": "^3.6.7",
"zod": "^4.4.3"
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,16 @@

Reviewed against GitHub master `9e6fca0a2ea400822bcb3c8380415025a1382d1c` on 2026-09-23.

## Zod 4.6.5: separate from PR #335

The original 19-update batch changes Zod 4.4.3 to 4.6.5. The focused App Kit
test `preserves direct v0 rejection issues instead of wrapping them in a v1
union error` fails: verification now adds a custom issue at
`manifest.navigation[0].module_id` alongside the existing `too_small` and
`unrecognized_keys` issues. The protocol explicitly freezes rejection issue
shapes. Do not weaken that assertion as part of routine maintenance.

Keep the direct API, shared and App Kit Zod versions at their master baseline
and exclude Zod from Dependabot's patch/minor group so it receives separate
review. A workspace override also holds transitive MCP and lint dependencies
at 4.4.3; remove it as part of the reviewed compatibility update. This is not a
security exception: the dependency audit reports no known vulnerabilities.

Re-entry criteria:

1. Reproduce with `pnpm --filter @deft/app-kit exec tsx --test
--test-name-pattern 'preserves direct v0 rejection' test/app-kit.test.ts`.
2. Compare old/new diagnostics for all frozen v0/v1/v2 malformed manifests and
packages, including unknown keys, missing modules, duplicate identities and
invalid navigation. Confirm accepted/rejected inputs remain unchanged.
3. Preserve the existing public error contract with a reviewed compatibility
adapter, or explicitly version any intended contract change. Do not filter
away validation errors or alter authority checks merely to pass the test.
4. Pass App Kit contracts, API/shared tests, workspace typecheck, web lint,
build, audit and required CI before merging the independent update.
## Zod 4.6.5: compatibility fix in PR #341

The original upgrade changed frozen unknown-key and union diagnostics. The
reviewed adapter restores abort behavior at the App/Module refinement
boundaries, retaining every error and preserving semantic rejection. The
workspace override is removed; Zod remains outside the routine update group.

See [the compatibility decision](2026-09-23-zod-contract-compatibility.md) for
the options, trust boundaries, rollback and validation. Future updates must
pass the committed diagnostics, package and packed-consumer regression tests.

## ESLint 10: compatibility adapter in PR #339

Expand Down
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-09-23-zod-contract-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Zod contract compatibility

## Decision

Upgrade the workspace to Zod 4.6.5 while preserving the App v0/v1/v2 and
Module v1/v2 public validation contracts. Remove the temporary 4.4.3 override.
Keep Zod outside the routine Dependabot group so future updates receive
contract validation independently.

Zod 4.6.5 makes `unrecognized_keys` issues continuable. In 4.4.3 they aborted
refinements and affected which union diagnostics were returned. A direct
upgrade changed 241 of 1,609 comparison cases, including nested unknown keys,
duplicate identities and invalid references. Valid inputs in that corpus did
not change.

Use one shared `abortOnUnknownContractKeys` helper at the existing App,
developer-compatibility, Module and collection refinement boundaries. It
retains every issue, restores `continue: false` for unknown-key failures, and
returns before semantic refinements run. The same helper ships in the portable
App Kit through the existing authoritative-source build. This preserves union
errors as well as the refinement behavior; guarding individual semantic rules
alone would not preserve union diagnostics.

Keeping the old workspace pin would postpone the upgrade. Changing the frozen
protocol or accepting new diagnostics would impose a compatibility change on
consumers. The small adapter preserves the published behavior without changing
accepted inputs, removing errors, patching Zod internals, or changing schema
construction methods available to callers.

## Boundaries and validation

Author-supplied JSON still passes the same strict structural and semantic
schemas before packaging or host use. Parsing still grants no authority; no
tenant, approval, persistence or runtime execution boundaries change. There
is no data migration. Reverting the PR restores the previous dependency pin
and validators without transforming stored data.

The 1,609-case before/after comparison covers valid and malformed example App
and Module manifests and matches after the adapter. Committed regression tests
cover root and nested unknown keys, duplicate identities, union branch errors,
and host/portable Kit parity. Existing package, canonicalization, protocol,
negative-corpus and packed-consumer tests remain unchanged. Run the complete
App Kit suite, workspace typecheck/build, lint, dependency audit and required
CI before merging. Future Zod upgrades must pass these same gates.
2 changes: 1 addition & 1 deletion packages/app-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"README.md"
],
"dependencies": {
"zod": "4.4.3"
"zod": "4.6.5"
},
"devDependencies": {
"@types/node": "^26.6.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/app-kit/scripts/build-module-validator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async function transpileAuthoritativeSource(name) {
throw new Error(`Expected authoritative Module schema import in ${sourcePath}`);
}
source = source.replace(originalImport, portableImport);
source = source.replace("from './zod-compat'", "from './zod-compat.js'");
}

const result = ts.transpileModule(source, {
Expand Down Expand Up @@ -50,4 +51,5 @@ async function transpileAuthoritativeSource(name) {
}

await transpileAuthoritativeSource('schemas');
await transpileAuthoritativeSource('zod-compat');
await transpileAuthoritativeSource('modules');
5 changes: 5 additions & 0 deletions packages/app-kit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { abortOnUnknownContractKeys } from './module-contract/zod-compat.js';
import {
classifyAppAutomationOccurrence,
resolveAppAutomationOccurrence,
Expand Down Expand Up @@ -69,6 +70,7 @@ export const DeftAppDeveloperCompatibilitySchema = z.strictObject({
'2': DeftAppDeveloperProtocolV2FlowSchema.optional(),
}),
}).superRefine((value, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
if (new Set(value.app_kit.versions).size !== value.app_kit.versions.length) {
ctx.addIssue({ code: 'custom', path: ['app_kit', 'versions'], message: 'App Kit versions must be unique' });
}
Expand Down Expand Up @@ -258,6 +260,7 @@ export const DeftAppManifestV0Schema = z
.default([]),
})
.superRefine((manifest, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
const identities = new Set<string>();
const moduleIds = new Set<string>();
const paths = new Set<string>();
Expand Down Expand Up @@ -623,6 +626,7 @@ export const DeftAppManifestV1Schema = z
actions: z.array(DeftAppActionBindingV1Schema).min(1).max(APP_LIMITS.actions),
})
.superRefine((manifest, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
const moduleIdentities = new Set<string>();
const moduleIds = new Set<string>();
const modulePaths = new Set<string>();
Expand Down Expand Up @@ -761,6 +765,7 @@ export const DeftAppManifestV2Schema = z
.max(APP_LIMITS.automation_requests),
})
.superRefine((manifest, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
const { automation_requests: _automationRequests, ...v1Fields } = manifest;
const v1Validation = DeftAppManifestV1Schema.safeParse({
...v1Fields,
Expand Down
3 changes: 3 additions & 0 deletions packages/app-kit/src/module-contract/zod-compat.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { z } from 'zod';

export function abortOnUnknownContractKeys(payload: z.RefinementCtx): boolean;
92 changes: 92 additions & 0 deletions packages/app-kit/test/validation-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import {
DEFT_APP_DEVELOPER_COMPATIBILITY,
DeftAppDeveloperCompatibilitySchema,
parseDeftAppManifest,
validateDeftModuleManifest,
} from '../dist/index.js';
import { parseSupportedDeftModuleManifest } from '../../shared/src/modules.js';
import { validEquipmentModule } from './fixtures/module-semantic-negative-corpus.js';

const root = resolve(import.meta.dirname, '../../..');

for (const example of [
'hello-workspace-app',
'connected-resource-campaigns-app',
'scheduled-connected-resource-campaigns-app',
]) {
for (const path of [[], ['modules', 0], ['navigation', 0]]) {
test(`${example}: unknown keys preserve rejection issues at ${path.join('.') || 'root'}`, () => {
const manifest = JSON.parse(readFileSync(resolve(root, 'examples', example, 'deft.app.json'), 'utf8'));
assert.doesNotThrow(() => parseDeftAppManifest(manifest));
manifest.modules.push(structuredClone(manifest.modules[0]));
assert.throws(() => parseDeftAppManifest(manifest), (error: unknown) => {
assert.ok(error && typeof error === 'object' && 'issues' in error);
assert.ok((error.issues as { code: string }[]).some(issue => issue.code === 'custom'));
return true;
});
let object = manifest;
for (const key of path) object = object[key];
object.runtime = {};
assert.throws(() => parseDeftAppManifest(manifest), (error: unknown) => {
assert.ok(error && typeof error === 'object' && 'issues' in error);
assert.deepEqual(error.issues, [{
code: 'unrecognized_keys', keys: ['runtime'], path,
message: 'Unrecognized key: "runtime"',
}]);
return true;
});
});
}
}

for (const schemaVersion of ['1', '2']) {
for (const path of [[], ['collections', 0], ['collections', 0, 'fields', 0]]) {
test(`Module ${schemaVersion}: unknown keys retain host/Kit parity at ${path.join('.') || 'root'}`, () => {
const manifest: Record<string, any> = structuredClone(validEquipmentModule);
manifest.schema_version = schemaVersion;
assert.doesNotThrow(() => parseSupportedDeftModuleManifest(manifest));
manifest.collections.push(structuredClone(manifest.collections[0]));
assert.throws(() => parseSupportedDeftModuleManifest(manifest));
let object = manifest;
for (const key of path) object = object[key];
object.runtime = {};
assert.throws(() => parseSupportedDeftModuleManifest(manifest), (error: unknown) => {
assert.ok(error && typeof error === 'object' && 'issues' in error);
// Both branches aborted on unknown keys before Zod 4.6; the public
// union diagnostic and branch ordering are part of that contract.
assert.deepEqual(error.issues, [{
code: 'invalid_union', path: [], message: 'Invalid input',
errors: ['1', '2'].map(version => [
...(version === schemaVersion ? [] : [{
code: 'invalid_value', values: [version], path: ['schema_version'],
message: `Invalid input: expected "${version}"`,
}]),
{ code: 'unrecognized_keys', keys: ['runtime'], path,
message: 'Unrecognized key: "runtime"' },
]),
}]);
return true;
});
const result = validateDeftModuleManifest(manifest);
assert.equal(result.success, false);
assert.equal(result.issues.length, 1);
assert.equal(result.issues[0]?.reason, 'Invalid input');
});
}
}

test('developer compatibility keeps unknown-key diagnostics without accepting duplicate versions', () => {
const value = structuredClone(DEFT_APP_DEVELOPER_COMPATIBILITY);
const invalid = { ...value, app_kit: { ...value.app_kit, versions: [...value.app_kit.versions, value.app_kit.versions[0]] } };
assert.equal(DeftAppDeveloperCompatibilitySchema.safeParse(invalid).success, false);
const result = DeftAppDeveloperCompatibilitySchema.safeParse({ ...invalid, runtime: {} });
assert.equal(result.success, false);
if (!result.success) assert.deepEqual(result.error.issues, [{
code: 'unrecognized_keys', keys: ['runtime'], path: [],
message: 'Unrecognized key: "runtime"',
}]);
});
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"./rich-text": "./src/rich-text.ts"
},
"dependencies": {
"zod": "^4.4.3"
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^26.6.2",
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/modules.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { abortOnUnknownContractKeys } from './zod-compat';
import { SEMVER_REGEX } from './schemas';

/**
Expand Down Expand Up @@ -496,6 +497,7 @@ export const ModuleCollectionSchema = z
latest_related: z.array(ModuleRelatedLatestSchema).max(4).optional(),
})
.superRefine((collection, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
addDuplicateIssues(
collection.fields.map((field) => field.key),
['fields'],
Expand Down Expand Up @@ -674,6 +676,7 @@ export const ModuleCollectionV2Schema = z
latest_related: z.array(ModuleRelatedLatestSchema).max(4).optional(),
})
.superRefine((collection, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
// Reuse the exact v1 collection invariants by projecting resource refs to
// the already non-inline v1 relation shape. This keeps defaults, views,
// search restrictions, duplicate checks, and all scalar behavior aligned.
Expand Down Expand Up @@ -736,6 +739,7 @@ export const DeftModuleManifestV1Schema = z
navigation: ModuleNavigationSchema.optional(),
})
.superRefine((manifest, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
validateLatestRelated(manifest.collections, ctx);
addDuplicateIssues(
manifest.collections.map((collection) => collection.key),
Expand Down Expand Up @@ -796,6 +800,7 @@ export const DeftModuleManifestV2Schema = z
navigation: ModuleNavigationSchema.optional(),
})
.superRefine((manifest, ctx) => {
if (abortOnUnknownContractKeys(ctx)) return;
validateLatestRelated(manifest.collections, ctx);
addDuplicateIssues(
manifest.collections.map((collection) => collection.key),
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/src/zod-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { z } from 'zod';

/**
* App/Module protocols freeze Zod 4.4's unknown-key diagnostics, including
* refinement short-circuiting and union branch errors. Zod 4.6 makes these
* issues continuable. Retain every issue, but restore its abort flag before
* downstream checks or containing unions inspect the parse result.
*/
export function abortOnUnknownContractKeys(payload: z.RefinementCtx): boolean {
let aborted = false;
for (const [index, issue] of payload.issues.entries()) {
if (issue.code === 'unrecognized_keys') {
payload.issues[index] = { ...issue, continue: false };
aborted = true;
}
}
return aborted;
}
Loading
Loading