From 96c2451270afc99fa5fa04ee2cc382392bf36337 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 10 Aug 2026 05:13:40 +0000 Subject: [PATCH 1/2] fix(policies): decode the capability mask when reopening a policy A saved policy stores only the compiled mask, so the capability and level pickers reopened empty and an operator could not see or amend the requirement they had set. Decode the mask against the catalog instead: each row owns one bit, so the names follow from the bits and nothing is persisted twice. --- .../policy-config-form/decode-mask.test.ts | 45 +++++++++++++++++++ .../policy-config-form/dynamic-form-field.tsx | 44 ++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts diff --git a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts new file mode 100644 index 0000000..3421225 --- /dev/null +++ b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/decode-mask.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityNode } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; +import { decodeMask } from './dynamic-form-field'; + +const bit = (bitnum: number, width = 64) => '0'.repeat(width - 1 - bitnum) + '1' + '0'.repeat(bitnum); + +const capability = (name: string, bitnum: number, kind: CapabilityNode['kind'] = 'permission'): CapabilityNode => ({ + id: name, + name, + bitnum, + bitstr: bit(bitnum), + description: null, + kind, +}); + +const catalog = [ + capability('admin_members', 0), + capability('create_entity', 4), + capability('add_credits', 6), + capability('level.trusted', 40, 'level'), +]; + +describe('decodeMask', () => { + it('names the bits the mask sets', () => { + const mask = bit(0).split(''); + mask[63 - 6] = '1'; + expect(decodeMask(mask.join(''), catalog, 'permission')).toEqual(['admin_members', 'add_credits']); + }); + + it('returns nothing for an empty mask', () => { + expect(decodeMask('0'.repeat(64), catalog, 'permission')).toEqual([]); + }); + + it('keeps levels out of the capability picker and vice versa', () => { + expect(decodeMask(bit(40), catalog, 'permission')).toEqual([]); + expect(decodeMask(bit(40), catalog, 'level')).toEqual(['level.trusted']); + }); + + // A policy compiled before the module grew is narrower than the catalog's + // bitstrings; bits are numbered from the right, so both still line up. + it('reads a mask narrower than the catalog', () => { + expect(decodeMask(bit(4, 17), catalog, 'permission')).toEqual(['create_entity']); + }); +}); diff --git a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx index d0d7a0c..d96fce8 100644 --- a/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx +++ b/packages/schema-builder/src/schema/schema-builder-policies/components/policies/policy-config-form/dynamic-form-field.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Combobox, ComboboxEmpty, @@ -25,7 +25,7 @@ import { Info } from 'lucide-react'; import { MEMBERSHIP_TYPES } from '@/blocks/schema/schema-builder-core/lib/constants/membership-types'; import type { PolicyTableData } from '@/blocks/schema/schema-builder-core/lib/gql/hooks/schema-builder/policies/use-database-policies'; -import type { CapabilityKind } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; +import type { CapabilityKind, CapabilityNode } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; import { useCapabilities } from '../../../lib/gql/hooks/schema-builder/policies/use-capabilities'; import { MultiValueFieldEditor } from '../multi-value-field-editor'; @@ -212,6 +212,28 @@ function DependentFieldSelectField({ ); } +/** Whether two bitstrings of possibly different widths share a set bit. */ +function bitsIntersect(a: string, b: string) { + for (let i = 1; i <= Math.min(a.length, b.length); i += 1) { + if (a[a.length - i] === '1' && b[b.length - i] === '1') return true; + } + return false; +} + +/** + * The names a mask requires, per the catalog it was compiled against. + * + * A saved policy stores only the compiled mask, so reopening one has to decode + * it: each catalog row owns one bit, and a name is required when its bit is set. + * Nothing is persisted twice — the mask stays the single source of truth. + */ +export function decodeMask(mask: string, capabilities: CapabilityNode[], kind: CapabilityKind) { + return capabilities + .filter((capability) => capability.name && capability.kind === kind && capability.bitstr) + .filter((capability) => bitsIntersect(mask, capability.bitstr as string)) + .map((capability) => capability.name); +} + /** * Multi-select over the capability catalog, populated from useCapabilities. * @@ -253,14 +275,28 @@ function CapabilitySelectField({ [capabilitiesList, kind], ); + // An unedited policy carries no names, only the mask the parser compiled. + // Once the operator has touched the picker their selection wins, so clearing + // it does not snap back to the mask it was opened with. + const [isTouched, setIsTouched] = useState(false); + const selected = useMemo(() => { + if (value) return value; + const mask = formData?.mask; + if (isTouched || typeof mask !== 'string') return []; + return decodeMask(mask, capabilitiesList, kind); + }, [value, formData?.mask, isTouched, capabilitiesList, kind]); + const noun = kind === 'level' ? 'levels' : 'capabilities'; const scope = isAppLevel ? 'app' : 'membership'; return ( onChange(next.length > 0 ? next : undefined)} + defaultValue={selected} + onValueChange={(next) => { + setIsTouched(true); + onChange(next.length > 0 ? next : undefined); + }} disabled={disabled} placeholder={isLoading ? 'Loading...' : `Select ${scope} ${noun}`} emptyIndicator={`No ${scope} ${noun} defined`} From df773db8fabc40bc64078f0d7ca6e4594e9bfbfc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 10 Aug 2026 05:19:10 +0000 Subject: [PATCH 2/2] fix(registry): pin the published data and command-palette ranges Caret ranges on 0.x majors do not cross minors, so `^0.5.0` stopped matching once `@constructive-io/data` published 0.7.0 and the registry smoke install failed to resolve it. Track the published minors and derive the contract test's expectation from the constant. --- apps/blocks/registry.json | 6 +++--- apps/registry/scripts/compiler.test.ts | 5 ++++- apps/registry/scripts/compiler.ts | 4 ++-- packages/schema-builder/registry.json | 2 +- packages/sheets/registry.json | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index a1296c6..2483e58 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -269,7 +269,7 @@ ], "docs": "## Usage\n\nCreate one registry for the application, adapt navigation at the host boundary, and render the palette near the root layout. Pages can register commands while mounted with `usePageCommands`.\n\n```tsx\nimport { createCommandRegistry, kbd } from '@constructive-io/command-palette';\nimport { CommandPalette } from '@/blocks/command-palette/command-palette';\n\nconst registry = createCommandRegistry({\n groups: [{ id: 'navigation', label: 'Navigation', priority: 1 }],\n commands: [{\n id: 'settings',\n label: 'Open settings',\n type: 'navigation',\n group: 'navigation',\n href: '/settings',\n shortcut: kbd(',', 'mod')\n }]\n});\n\n router.push(href)} />;\n```\n\nThe installed block owns presentation and keyboard interaction. The headless package owns command registration, execution, multi-step state, and background-task lifecycle. Route authorization, action permissions, errors, and business workflows remain the host application's responsibility.", "dependencies": [ - "@constructive-io/command-palette@^0.4.0", + "@constructive-io/command-palette@^0.5.0", "lucide-react", "motion" ], @@ -436,7 +436,7 @@ "description": "The leaf-independent Console Kit shell, runtime, discovery, and single modular Zustand store.", "docs": "`console-kit-core` installed the shell, runtime, semantic routing, callback boundary, and one per-instance modular Zustand store. Core intentionally includes no feature view, so add selected `console-module-*` items.\n\nDegraded states: explicit endpoint, current `_meta`, introspection, capability, and adapter evidence fail closed independently; installation never grants authority.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@tanstack/react-query", "graphql", "lucide-react", @@ -566,7 +566,7 @@ "description": "A current-_meta-only application data explorer with application-table filtering and spreadsheet CRUD.", "docs": "`feature-pack-data` installed a provider-neutral view and `.constructive/feature-packs/data.json`. Import from `@/blocks/feature-packs/data/data-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-data` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: The host owns Sheets state and endpoint binding; incompatible metadata stays explicit, and PostgreSQL privileges and RLS decide every query and mutation.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/data/", "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "lucide-react" ], "registryDependencies": [ diff --git a/apps/registry/scripts/compiler.test.ts b/apps/registry/scripts/compiler.test.ts index 03fe929..94de12f 100644 --- a/apps/registry/scripts/compiler.test.ts +++ b/apps/registry/scripts/compiler.test.ts @@ -397,7 +397,10 @@ test('enforces the compiled registry distribution contract', () => { const wrongRange = structuredClone(distributionContractFixture()); wrongRange.find((item) => item.name === 'consumer')!.dependencies = ['@constructive-io/data@^1.0.0']; - assert.throws(() => assertRegistryDistributionContract(wrongRange), /must depend on @constructive-io\/data@\^0\.5\.0/); + assert.throws( + () => assertRegistryDistributionContract(wrongRange), + new RegExp(`must depend on ${CONSTRUCTIVE_DATA_DEPENDENCY.replace(/[.^]/g, '\\$&')}`), + ); const sourceOwnedPackage = structuredClone(distributionContractFixture()); sourceOwnedPackage.find((item) => item.name === 'consumer')!.dependencies = [CONSTRUCTIVE_UI_PACKAGE]; diff --git a/apps/registry/scripts/compiler.ts b/apps/registry/scripts/compiler.ts index cfd3632..ec716a7 100644 --- a/apps/registry/scripts/compiler.ts +++ b/apps/registry/scripts/compiler.ts @@ -5,8 +5,8 @@ export const CONSTRUCTIVE_UI_PACKAGE = '@constructive-io/ui'; export const CONSTRUCTIVE_SHEETS_PACKAGE = '@constructive-io/sheets'; export const CONSTRUCTIVE_NAMESPACE = '@constructive/'; export const CONSTRUCTIVE_THEME_DEPENDENCY = '@constructive/constructive-theme'; -export const CONSTRUCTIVE_DATA_DEPENDENCY = '@constructive-io/data@^0.5.0'; -export const CONSTRUCTIVE_COMMAND_PALETTE_DEPENDENCY = '@constructive-io/command-palette@^0.4.0'; +export const CONSTRUCTIVE_DATA_DEPENDENCY = '@constructive-io/data@^0.7.0'; +export const CONSTRUCTIVE_COMMAND_PALETTE_DEPENDENCY = '@constructive-io/command-palette@^0.5.0'; export const NODE_TYPE_REGISTRY_DEPENDENCY = 'node-type-registry@^1.11.0'; export const FEATURE_PACK_IDS = [ diff --git a/packages/schema-builder/registry.json b/packages/schema-builder/registry.json index c4057a5..7e2262c 100644 --- a/packages/schema-builder/registry.json +++ b/packages/schema-builder/registry.json @@ -14,7 +14,7 @@ "schema" ], "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@dnd-kit/core", "@dnd-kit/utilities", "@fluentui/react-context-selector", diff --git a/packages/sheets/registry.json b/packages/sheets/registry.json index dcbbc2b..64d6c1d 100644 --- a/packages/sheets/registry.json +++ b/packages/sheets/registry.json @@ -14,7 +14,7 @@ "data" ], "dependencies": [ - "@constructive-io/data@^0.5.0", + "@constructive-io/data@^0.7.0", "@internationalized/date", "@remixicon/react", "@tanstack/react-form",