diff --git a/.changeset/app-dead-authoring-keys.md b/.changeset/app-dead-authoring-keys.md new file mode 100644 index 0000000000..44d4c2e333 --- /dev/null +++ b/.changeset/app-dead-authoring-keys.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: tombstone the seven dead AppSchema authoring keys (#4001 app step, PR A) + +The 2026-06 AppSchema liveness audit verdicted seven authorable keys DEAD — +never read by any consumer in framework or objectui. Authoring them shipped +config the author believed was in effect; `sharing`/`embed` were the +dangerous case (a declared public-access surface no route enforced, +ADR-0049 class). All seven are now `retiredKey()` tombstones: `tsc`-level +`never` for typed authors, a parse-time prescription for everyone else — +NOT a silent strip, because `AppSchema` is not yet `.strict()`. + +**Removed keys and their prescriptions (FROM → TO):** + +- `App.version` → an app is versioned by its owning package: `manifest.version`. +- `App.aria` → declare `aria` on the component/widget that renders the DOM node. +- `App.objects` / `App.apis` → the self-described "config file convenience"; + objects/apis belong to the stack (`defineStack({ objects, apis })`) — the + chatbot derives an app's object list from its NAV ITEMS, never from these. +- `App.sharing` / `App.embed` → public access is granted per FORM VIEW + (`FormView.sharing`, the public-data-collection surface); no public-app or + iframe route ever read the app-level blocks. +- `App.mobileNavigation` → fully unimplemented (even `packages/mobile` + ignored it); returns if/when a real mobile navigation ships. + +Deleting a key is behavior-preserving by construction — none ever had a +runtime effect. `os migrate meta --from <16 or lower>` rewrites your source +(the `app-dead-authoring-keys-removed` conversion, ADR-0087), or delete the +keys by hand. + +This clears the ADR-0049 precondition for PR B (AppSchema + navigation-union +`.strict()`): strictness should guard the real contract, not dead keys. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index a4f74cc70c..7a85ee3427 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -68,7 +68,7 @@ const result = AppDefinitionResponse.parse(data); | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED' \| 'ACCOUNT_LOCKED' \| 'ALREADY_REVERTED' \| 'AMBIGUOUS_MATCH' \| 'ANALYTICS_QUERY_FAILED' \| 'APPROVAL_ACTIONS_FAILED' \| 'APPROVAL_RECALL_FAILED' \| 'APPROVAL_REQUEST_GET_FAILED' \| 'APPROVAL_REQUEST_LIST_FAILED' \| 'ASYNC_NOT_SUPPORTED' \| 'ATTACHMENT_DELETE_DENIED' \| 'ATTACHMENT_DOWNLOAD_DENIED' \| 'ATTACHMENT_PARENT_ACCESS' \| 'AUDIENCE_NOT_ALLOWED' \| 'AUTH_CONFIG_ERROR' \| 'AUTH_REQUIRED' \| 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS' \| 'BATCH_ABORTED' \| 'BATCH_NOT_ATOMIC' \| 'BATCH_TOO_LARGE' \| 'BATCH_UNRESOLVED_REF' \| 'BLANK_MATCH_KEY' \| 'CLONE_DISABLED' \| 'CLOUD_FETCH_FAILED' \| 'CLOUD_UNCONFIGURED' \| 'COMMIT_NOT_FOUND' \| 'CONCURRENT_UPDATE' \| 'CONFLICTING_MAPPING' \| 'CONNECTOR_UPSTREAM_UNAVAILABLE' \| 'CREATE_FAILED' \| 'CUBE_NOT_FOUND' \| 'DATASET_INVALID' \| 'DATASOURCE_ADMIN_ERROR' \| 'DELEGABLE_SCOPE_FAILED' \| 'DELIVERY_NOT_ELIGIBLE' \| 'DESTRUCTIVE_CHANGE' \| 'DEVICE_CODE_FAILED' \| 'DOMAIN_VERIFICATION_DISABLED' \| 'DOMAIN_VERIFICATION_FAILED' \| 'DRIVER_UNAVAILABLE' \| 'DUPLICATE_REQUEST' \| 'EMAIL_SEND_FAILED' \| 'EMAIL_SERVICE_REQUIRED' \| 'ENQUEUE_FAILED' \| 'ENVIRONMENT_BIND_FAILED' \| 'ENVIRONMENT_NOT_FOUND' \| 'ENV_ACCESS_DENIED' \| 'ERR_BULK_RESULT_MISMATCH' \| 'ERR_DATASOURCE_UNAVAILABLE' \| 'ERR_DRIVER_CONNECT' \| 'ERR_FILE_CONSTRAINT' \| 'ERR_FILE_REFERENCE_COPY' \| 'ERR_SUMMARY_RECOMPUTE' \| 'EXECUTION_ERROR' \| 'EXPIRED_OR_REVOKED' \| 'EXPIRY_IN_PAST' \| 'EXPIRY_TOO_LONG' \| 'EXPLAIN_FAILED' \| 'EXPORT_NOT_PERMITTED' \| 'EXTERNAL_IMPORT_ERROR' \| 'EXTERNAL_SCHEMA_MISMATCH' \| 'EXTERNAL_SCHEMA_MODE_VIOLATION' \| 'EXTERNAL_WRITE_FORBIDDEN' \| 'FEEDS_DISABLED' \| 'FILES_DISABLED' \| 'FILE_DOWNLOAD_DENIED' \| 'FILE_NOT_FOUND' \| 'FILTER_TOKEN_UNKNOWN' \| 'FILTER_TOKEN_UNRESOLVED' \| 'FORBIDDEN' \| 'FORM_NOT_FOUND' \| 'FORM_RESOLVE_FAILED' \| 'IMPORT_JOB_CREATE_FAILED' \| 'IMPORT_ROW_FAILED' \| 'INTERNAL' \| 'INVALID_EMAIL' \| 'INVALID_EXPIRY' \| 'INVALID_METADATA' \| 'INVALID_OR_EXPIRED' \| 'INVALID_PHONE' \| 'INVALID_REQUEST' \| 'INVALID_RESUME_TOKEN' \| 'INVALID_SIGNAL' \| 'INVALID_SIGNATURE' \| 'INVALID_STATE' \| 'INVITE_EMAIL_FAILED' \| 'INVITE_REQUIRES_EMAIL' \| 'INVITE_SMS_FAILED' \| 'IP_NOT_ALLOWED' \| 'ITEM_LOCKED' \| 'LAST_LOCAL_CREDENTIAL' \| 'LOOKUP_NOT_PUBLIC' \| 'LOOKUP_TARGET_MISSING' \| 'MANIFEST_CONFLICT' \| 'MAPPING_FORMAT_MISMATCH' \| 'MAPPING_FORMAT_UNSUPPORTED' \| 'MAPPING_NOT_FOUND' \| 'MAPPING_TARGET_MISMATCH' \| 'MARKETPLACE_PROXY_FAILED' \| 'MARKETPLACE_STORAGE_FAILED' \| 'MARKETPLACE_UNAVAILABLE' \| 'METADATA_BRANCH' \| 'METADATA_CONFLICT' \| 'METADATA_NOT_FOUND' \| 'METADATA_SCHEMA_INVALID' \| 'MONGODB_MULTI_TENANT_UNSUPPORTED' \| 'NAMESPACE_PREFIX' \| 'NEEDS_PASSWORD' \| 'NODE_FAILURE' \| 'NOTHING_TO_PURGE' \| 'NOT_CREATABLE' \| 'NOT_FOUND' \| 'NOT_OVERRIDABLE' \| 'NOT_UNDOABLE' \| 'NO_DRAFT' \| 'NO_EXECUTOR' \| 'NO_IDENTITY' \| 'NO_MATCH' \| 'NO_PENDING_VERIFICATION' \| 'OAUTH_REGISTER_FAILED' \| 'OBJECT_API_DISABLED' \| 'OBJECT_API_METHOD_NOT_ALLOWED' \| 'OPENAPI_UNAVAILABLE' \| 'OS_PROTOCOL_INCOMPATIBLE' \| 'OVERLAY_PERSISTENCE_FAILED' \| 'PACKAGE_DELETE_FAILED' \| 'PACKAGE_DELETE_PARTIAL' \| 'PACKAGE_MANIFEST_INVALID' \| 'PACKAGE_PUBLISH_FAILED' \| 'PASSWORD_ALREADY_SET' \| 'PASSWORD_EXPIRED' \| 'PASSWORD_POLICY_VIOLATION' \| 'PASSWORD_REUSE' \| 'PAYLOAD_TOO_LARGE' \| 'PERMISSION_NOT_ALLOWED' \| 'PHONE_NOT_ENABLED' \| 'PLUGIN_INSTALL_FAILED' \| 'PLUGIN_MANIFEST_INVALID' \| 'PLUGIN_REGISTER_FAILED' \| 'PROJECT_MEMBERSHIP_REQUIRED' \| 'PROJECT_NOT_FOUND' \| 'PROJECT_PROVISIONING' \| 'PROJECT_PROVISIONING_FAILED' \| 'RAW_SQL_UNSUPPORTED' \| 'RECORD_GONE' \| 'RECORD_LOCKED' \| 'REPORTS_LIST_FAILED' \| 'REPORT_DELETE_FAILED' \| 'REPORT_GET_FAILED' \| 'REPORT_NOT_FOUND' \| 'REPORT_RUN_FAILED' \| 'REPORT_SAVE_FAILED' \| 'REPORT_SCHEDULE_FAILED' \| 'REQUEST_NOT_FOUND' \| 'RESEED_NO_ROWS' \| 'RESEED_SKIPPED' \| 'ROUTE_NOT_FOUND' \| 'RULE_DEFINE_FAILED' \| 'RULE_DELETE_FAILED' \| 'RULE_EVALUATE_FAILED' \| 'RULE_GET_FAILED' \| 'RULE_LIST_FAILED' \| 'RULE_NOT_FOUND' \| 'SAML_REGISTER_FAILED' \| 'SCHEDULES_LIST_FAILED' \| 'SCHEDULE_DELETE_FAILED' \| 'SETTINGS_ACTION_FAILED' \| 'SETTINGS_FORBIDDEN' \| 'SETTINGS_LOCKED' \| 'SETTINGS_UNKNOWN_KEY' \| 'SETTINGS_UNKNOWN_NAMESPACE' \| 'SETTINGS_VALIDATION' \| 'SHARES_LIST_FAILED' \| 'SHARE_GRANT_FAILED' \| 'SHARE_REVOKE_FAILED' \| 'SHARING_NOT_ENABLED' \| 'SIGN_IN_REQUIRED' \| 'SSO_REGISTER_FAILED' \| 'SSO_REGISTER_FORBIDDEN' \| 'SUGGESTION_CONFIRM_FAILED' \| 'SUGGESTION_DISMISS_FAILED' \| 'SUGGESTION_LIST_FAILED' \| 'SUGGESTION_NOT_FOUND' \| 'SUGGESTION_STATE' \| 'SUMMARY_RECOMPUTE_FAILED' \| 'UNAUTHORIZED' \| 'UNIQUE_VIOLATION' \| 'UNKNOWN_KEY' \| 'UNKNOWN_NAMESPACE' \| 'UNSUPPORTED' \| 'UNSUPPORTED_QUERY_PARAM' \| 'UNSUPPORTED_TRANSFORM' \| 'UPLOAD_SESSION_NOT_FOUND' \| 'USER_ALREADY_EXISTS' \| 'VALIDATION_FAILED' \| 'VERSION_NOT_FOUND' \| 'VERSION_NOT_RESTORABLE' \| 'WRITABLE_PACKAGE_REQUIRED' \| 'WRONG_PASSWORD'>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ name: string; label: string; version?: string; description?: string; … }` | ✅ | Full App Configuration | +| **data** | `{ name: string; label: string; version?: any; description?: string; … }` | ✅ | Full App Configuration | --- diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index f2705d35cf..ae3a64e05e 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -73,7 +73,7 @@ const result = ActionNavItem.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | App unique machine name (lowercase snake_case) | | **label** | `string` | ✅ | App display label | -| **version** | `string` | optional | App version | +| **version** | `any` | optional | [REMOVED] `App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no consumer in framework or objectui). An app is versioned by its owning package: use `manifest.version`. Delete the key. | | **description** | `string` | optional | App description | | **icon** | `string` | optional | App icon used in the App Launcher | | **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding | @@ -85,13 +85,13 @@ const result = ActionNavItem.parse(data); | **contextSelectors** | `{ id: string; label: string; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | | **homePageId** | `string` | optional | ID of the navigation item to serve as landing page | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | -| **objects** | `any[]` | optional | Objects belonging to this app | -| **apis** | `any[]` | optional | Custom APIs belonging to this app | -| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration | -| **embed** | `{ enabled?: boolean; allowedOrigins?: string[]; width?: string; height?: string; … }` | optional | Iframe embedding configuration | -| **mobileNavigation** | `{ mode?: Enum<'drawer' \| 'bottom_nav' \| 'hamburger'>; bottomNavItems?: string[] }` | optional | Mobile-specific navigation configuration | +| **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. | +| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), not the app shell. Delete the key. | +| **sharing** | `any` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. | +| **embed** | `any` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. | +| **mobileNavigation** | `any` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | -| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the application | +| **aria** | `any` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the component/widget that renders the DOM node instead. Delete the key. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 0f0ce627e6..e3abaca579 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -789,6 +789,14 @@ somewhere else and did, in three different somewhere-elses: `error.details.code` ### Dead spec clusters removed +**App shell (2026-06 liveness audit, #4001 app step).** `App.version`, +`App.aria`, `App.objects`, `App.apis`, `App.sharing`, `App.embed` and +`App.mobileNavigation` are tombstoned — none was ever read by framework or +objectui. `sharing`/`embed` were the dangerous pair: a declared public-access +surface no route enforced (the live path is `FormView.sharing`). +`mobileNavigation` was a mode picker that changed nothing. Each key rejects +with its prescription; `os migrate meta` deletes them from your source. + Each of these parsed and did nothing. None has a runtime consumer; delete the import or the authored key. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 35bd074b61..692bcc8478 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -127,7 +127,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `view.zod.ts` | 51 | authorable | partially strict (ADR-0089); long tail of sub-blocks | | `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | | `theme.zod.ts` | 14 | authorable (p) | authored themes | -| `app.zod.ts` | 11 | authorable | **next verified step** — `AppSchema` + nav-item union; recursive `NavigationItemSchema` needs union-error care | +| `app.zod.ts` | 11 | authorable | **PR A done (#4001 app step): the seven audit-dead keys (`version`/`aria`/`objects`/`apis`/`sharing`/`embed`/`mobileNavigation`) are `retiredKey()` tombstones + an ADR-0087 conversion** — the ADR-0049 precondition for strict. **PR B next**: `AppSchema` + nav union `.strict()`; the union-error question is settled — convert `NavigationItemSchema` to `z.discriminatedUnion('type', …)` (verified: matched-branch-only unknown-key errors, precise recursive paths, `toJSONSchema` clean) | | `dashboard.zod.ts` | 11 | authorable | partially strict | | `widget.zod.ts` | 9 | authorable (p) | | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | @@ -219,5 +219,10 @@ schemas, with the ADR-0019 re-home map as wrong-layer guidance (`steps` / `entryCriteria` / `onApprove` / `onReject` / `rejectionBehavior` each point at where the concept lives on the flow graph now). +Done in the app step, PR A: the seven audit-dead AppSchema keys tombstoned +(`retiredKey` + `app-dead-authoring-keys-removed` conversion + step-17 +migration entry), clearing the enforce-or-remove precondition for the app +strict step (PR B). + Long tail stays gated on a verification pass per shape — never a one-shot "make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation). diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 0898cac681..44aa216d0b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -128,6 +128,8 @@ And it removes the RLS-policy key `priority` (#3896 security audit): promised "c The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription. +The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, #4001 app step): `version` (apps are versioned by manifest.version), `aria`, `objects`/`apis` (the self-described "config file convenience" — nothing read them; the chatbot derives an app's objects from its nav items), `sharing`/`embed` (a declared-but-unenforced public surface — the only live path is FormView.sharing; ADR-0049), and `mobileNavigation` (fully unimplemented). Pure lossless deletes — none ever had a runtime effect; each key is tombstoned with its prescription. + ADR-0113 splits the `required` tri-binding: post-17, `required` is ONLY the write-time contract (insert must provide; update may not null out; legacy null rows rest), and the physical NOT NULL is the explicit `storage.notNull`. The `field-required-notnull-explicit` conversion preserves every pre-17 source verbatim-in-meaning by stamping `storage.notNull: true` onto each required field — under the old semantics that column WAS created NOT NULL, so the rewrite writes down what the text already meant. Migration-chain-only (retired from the load path): this is a default flip, not a rename, and a loader that auto-applied it would stamp the constraint onto 17-authored sources that deliberately omit it. On the wire contract it also retires the `/analytics/query` request ENVELOPE (#3878): `AnalyticsQueryRequestSchema` used to describe `{ cube, query: {...}, format }` — the dialect of the retired degraded analytics shim (#3891) that the real engine never understood (an envelope body inferred a column-less cube and died as an SQL syntax error). The canonical request body is now the BARE AnalyticsQuery — `cube` + `measures` at the top level — which is what every real caller already sends; the schema tombstones `query`/`format`, and the dispatcher entry validates bodies and answers 400 with the prescription. No stored metadata carries this shape (it was HTTP-only), so the change is two semantic TODOs for API callers rather than a stack conversion. @@ -147,6 +149,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | | `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | | `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only | +| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' removed (2026-06 liveness audit — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented) | retired — `migrate meta` only | | `field-required-notnull-explicit` | `object.fields.*.required / object.fields.*.storage.notNull` | required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only | | `action-inert-keys-removed` | `action.shortcut / action.bulkEnabled` | action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only | | `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only | diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index a6f707d3c2..b6117d4f4b 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7514,27 +7514,27 @@ "ui/App:_packageVersion", "ui/App:_provenance", "ui/App:active", - "ui/App:apis", + "ui/App:apis [RETIRED]", "ui/App:areas", - "ui/App:aria", + "ui/App:aria [RETIRED]", "ui/App:branding", "ui/App:contextSelectors", "ui/App:defaultAgent", "ui/App:description", - "ui/App:embed", + "ui/App:embed [RETIRED]", "ui/App:hidden", "ui/App:homePageId", "ui/App:icon", "ui/App:isDefault", "ui/App:label", - "ui/App:mobileNavigation", + "ui/App:mobileNavigation [RETIRED]", "ui/App:name", "ui/App:navigation", - "ui/App:objects", + "ui/App:objects [RETIRED]", "ui/App:protection", "ui/App:requiredPermissions", - "ui/App:sharing", - "ui/App:version", + "ui/App:sharing [RETIRED]", + "ui/App:version [RETIRED]", "ui/AppBranding:accentColor", "ui/AppBranding:favicon", "ui/AppBranding:logo", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 9d5ffc7171..18e5114e26 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -122,6 +122,12 @@ "conversionId": "tool-inert-authoring-keys-removed", "toMajor": 17 }, + { + "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' removed (2026-06 liveness audit — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented)", + "conversionId": "app-dead-authoring-keys-removed", + "toMajor": 17 + }, { "surface": "object.fields.*.required / object.fields.*.storage.notNull", "to": "required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract)", @@ -507,6 +513,12 @@ "conversionId": "tool-inert-authoring-keys-removed", "toMajor": 17 }, + { + "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' removed (2026-06 liveness audit — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented)", + "conversionId": "app-dead-authoring-keys-removed", + "toMajor": 17 + }, { "surface": "object.fields.*.required / object.fields.*.storage.notNull", "to": "required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 43349c8e6c..44a1a32767 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1158,6 +1158,56 @@ const flowNodeScriptConfigAliases: MetadataConversion = { }, }; +/** + * App dead authoring keys removed (protocol 17, #4001 app step; 2026-06 + * AppSchema liveness audit). + * + * Seven keys were authorable and never read by any consumer in framework or + * objectui: `version` (an app is versioned by its package's + * `manifest.version`), `aria` (no renderer read app-level ARIA), `objects` / + * `apis` (the spec's own "config file convenience" — objects register via + * `defineStack`, and the chatbot derives an app's object list from its nav + * items), `sharing` / `embed` (declared-but-unenforced security surface — the + * only live path is `FormView.sharing`; ADR-0049 class), and + * `mobileNavigation` (fully unimplemented — even `packages/mobile` ignored + * it). Pure lossless deletes: none ever had a runtime effect. + * + * `retiredFromLoadPath`: the schema tombstones each key (`retiredKey`, tsc + * `never` + a parse-time prescription), same posture as its step-17 siblings. + */ +const appDeadAuthoringKeysRemoved: MetadataConversion = { + id: 'app-dead-authoring-keys-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation', + summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' removed (2026-06 liveness audit — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented)", + apply(stack, emit) { + const RETIRED = ['version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation']; + return mapCollection(stack, 'apps', (app, path) => stripKeys(app, RETIRED, emit, path)); + }, + fixture: { + before: { + apps: [{ + name: 'portal', + label: 'Portal', + version: '1.0.0', + sharing: { enabled: true }, + embed: { enabled: true }, + mobileNavigation: { mode: 'bottom_nav' }, + navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], + }], + }, + after: { + apps: [{ + name: 'portal', + label: 'Portal', + navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], + }], + }, + expectedNotices: 4, + }, +}; + /** * RLS-policy `priority` removed (protocol 17, #3896 security audit). * @@ -1673,6 +1723,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { apps: { sales: { label: 'Sales', - objects: ['account', 'contact'], + icon: 'briefcase', }, }, }; @@ -321,7 +321,7 @@ describe('defineStack - Map Format Support', () => { task: { fields: { title: { type: 'text' } } }, }, apps: [ - { name: 'sales', label: 'Sales', objects: ['account'] }, + { name: 'sales', label: 'Sales', icon: 'briefcase' }, ], }; diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 22715517cb..158d946230 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -557,25 +557,20 @@ describe('AppSchema', () => { expect(() => AppSchema.parse(hrApp)).not.toThrow(); }); - it('should accept app with sharing and embed config', () => { - const app = AppSchema.parse({ + it('rejects the retired app-level sharing/embed blocks with the per-form-view pointer', () => { + // Pre-#4001 these parsed and were never read — a declared-but-unenforced + // security surface (2026-06 liveness audit, ADR-0049). The tombstone + // carries the prescription: public access is per FORM VIEW. + const result = AppSchema.safeParse({ name: 'shared_portal', label: 'Shared Portal', navigation: [], - sharing: { - enabled: true, - allowAnonymous: true, - }, - embed: { - enabled: true, - allowedOrigins: ['https://example.com'], - responsive: true, - }, + sharing: { enabled: true }, + embed: { enabled: true }, }); - - expect(app.sharing?.enabled).toBe(true); - expect(app.embed?.enabled).toBe(true); - expect(app.embed?.allowedOrigins).toEqual(['https://example.com']); + expect(result.success).toBe(false); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('FormView.sharing'); }); it('should accept CRM app with deeply nested navigation tree', () => { @@ -639,36 +634,20 @@ describe('AppSchema', () => { }); }); -describe('App Mobile Navigation', () => { - it('should accept app with mobile navigation', () => { - const app = AppSchema.parse({ +describe('App Mobile Navigation (retired, #4001)', () => { + it('rejects the retired mobileNavigation block with the prescription', () => { + // Pre-#4001 these tests asserted a mode picker that changed NOTHING — the + // block was fully unimplemented (2026-06 liveness audit: even + // packages/mobile ignored it). The strip-era fiction is a loud error now. + const result = AppSchema.safeParse({ name: 'mobile_app', label: 'Mobile App', - mobileNavigation: { - mode: 'bottom_nav', - bottomNavItems: ['nav_home', 'nav_contacts', 'nav_settings'], - }, - }); - expect(app.mobileNavigation?.mode).toBe('bottom_nav'); - expect(app.mobileNavigation?.bottomNavItems).toHaveLength(3); - }); - it('should accept all mobile navigation modes', () => { - const modes = ['drawer', 'bottom_nav', 'hamburger'] as const; - modes.forEach(mode => { - expect(() => AppSchema.parse({ - name: 'mobile_test', - label: 'Test', - mobileNavigation: { mode }, - })).not.toThrow(); + mobileNavigation: { mode: 'bottom_nav' }, }); - }); - it('should default to drawer mode', () => { - const app = AppSchema.parse({ - name: 'default_mobile', - label: 'Default Mobile', - mobileNavigation: {}, - }); - expect(app.mobileNavigation?.mode).toBe('drawer'); + expect(result.success).toBe(false); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('mobileNavigation'); + expect(messages).toContain('unimplemented'); }); }); @@ -702,49 +681,25 @@ describe('defineApp', () => { }); }); -describe('AppSchema sharing and embed fields', () => { - it('should accept app with sharing config', () => { - const app = AppSchema.parse({ +describe('AppSchema retired sharing/embed keys (#4001)', () => { + it('rejects app-level sharing with the FormView prescription', () => { + const result = AppSchema.safeParse({ name: 'public_app', label: 'Public App', - sharing: { - enabled: true, - password: 'secret123', - allowedDomains: ['example.com'], - }, + sharing: { enabled: true }, }); - - expect(app.sharing?.enabled).toBe(true); - expect(app.sharing?.password).toBe('secret123'); - expect(app.sharing?.allowedDomains).toEqual(['example.com']); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); }); - it('should accept app with embed config', () => { - const app = AppSchema.parse({ + it('rejects app-level embed with the per-form-view prescription', () => { + const result = AppSchema.safeParse({ name: 'embeddable_app', label: 'Embeddable App', - embed: { - enabled: true, - allowedOrigins: ['https://portal.example.com'], - width: '100%', - height: '800px', - }, - }); - - expect(app.embed?.enabled).toBe(true); - expect(app.embed?.allowedOrigins).toEqual(['https://portal.example.com']); - }); - - it('should accept app with both sharing and embed', () => { - const app = AppSchema.parse({ - name: 'shared_embedded', - label: 'Shared & Embedded', - sharing: { enabled: true }, embed: { enabled: true }, }); - - expect(app.sharing?.enabled).toBe(true); - expect(app.embed?.enabled).toBe(true); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('iframe'); }); it('should accept app without sharing/embed (backward compatibility)', () => { @@ -1075,3 +1030,29 @@ describe('AppSchema with areas', () => { expect(app.areas![0].navigation).toHaveLength(7); }); }); + +// #4001 (app step, PR A) — the seven keys the 2026-06 AppSchema liveness audit +// verdicted DEAD are retiredKey() tombstones: tsc-level `never` for typed +// authors, and a parse-time prescription for everyone else. Not a silent +// strip: AppSchema is not yet .strict(), so a bare deletion would have +// reintroduced the exact failure mode this campaign eliminates. +describe('retired dead keys carry prescriptions (#4001)', () => { + it.each([ + ['version', '1.0.0', 'manifest.version'], + ['aria', { label: 'x' }, 'component/widget'], + ['objects', [], 'defineStack'], + ['apis', [], 'defineStack'], + ] as const)('rejects `%s` with its upgrade prescription', (key, value, fragment) => { + const result = AppSchema.safeParse({ name: 'app_x', label: 'X', [key]: value }); + expect(result.success).toBe(false); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(fragment); + }); + + it('an app without any retired key parses unchanged', () => { + expect(() => AppSchema.parse({ + name: 'clean_app', label: 'Clean', + navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 11f474bf17..bd334f23fd 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -3,8 +3,8 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { SharingConfigSchema, EmbedConfigSchema } from './sharing.zod'; +import { I18nLabelSchema } from './i18n.zod'; +import { retiredKey } from '../shared/retired-key'; /** * Base Navigation Item Schema @@ -549,9 +549,17 @@ export const AppSchema = lazySchema(() => z.object({ /** Display label */ label: I18nLabelSchema.describe('App display label'), - /** App version */ - version: z.string().optional().describe('App version'), - + /** + * REMOVED — never read by any consumer (2026-06 AppSchema liveness audit). + * An app's version is its owning package's `manifest.version`; a second + * per-app number had no reader and could silently disagree with it. + */ + version: retiredKey( + '`App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + + 'no consumer in framework or objectui). An app is versioned by its owning package: ' + + 'use `manifest.version`. Delete the key.', + ), + /** Description */ description: I18nLabelSchema.optional().describe('App description'), @@ -635,26 +643,54 @@ export const AppSchema = lazySchema(() => z.object({ */ requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this app'), - /** - * Package Components (For config file convenience) - * In a real monorepo these might be auto-discovered, but here we allow explicit registration. + /** + * REMOVED — the self-described "config file convenience" slots were never + * read (2026-06 liveness audit): objects register via `defineStack`, and the + * ambient chatbot derives an app's object list from its NAV ITEMS + * (`collectNavObjects`), never from `App.objects`. */ - objects: z.array(z.unknown()).optional().describe('Objects belonging to this app'), - apis: z.array(z.unknown()).optional().describe('Custom APIs belonging to this app'), - - /** Sharing configuration for public access */ - sharing: SharingConfigSchema.optional().describe('Public sharing configuration'), + objects: retiredKey( + '`App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + + 'never read; the spec itself labelled it "config file convenience"). Objects belong ' + + 'to the stack (`defineStack({ objects })`); an app reaches them through its ' + + 'navigation items. Delete the key.', + ), + apis: retiredKey( + '`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + + 'never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), ' + + 'not the app shell. Delete the key.', + ), - /** Embed configuration for iframe embedding */ - embed: EmbedConfigSchema.optional().describe('Iframe embedding configuration'), + /** + * REMOVED — a declared-but-unenforced security surface (ADR-0049 class, + * 2026-06 liveness audit): the only live sharing/embed path is + * `FormView.sharing` (public data collection); no public-app or iframe route + * ever read the app-level blocks, so authoring them created a false + * security/feature impression. + */ + sharing: retiredKey( + '`App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ' + + 'ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared ' + + 'sharing that did not exist. Public access is granted per FORM VIEW ' + + '(`FormView.sharing`, the public-data-collection surface). Delete the key.', + ), + embed: retiredKey( + '`App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ' + + 'ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface ' + + '(`FormView.sharing`), not an app-level switch. Delete the key.', + ), - /** Mobile navigation mode */ - mobileNavigation: z.object({ - mode: z.enum(['drawer', 'bottom_nav', 'hamburger']).default('drawer') - .describe('Mobile navigation mode: drawer sidebar, bottom navigation bar, or hamburger menu'), - bottomNavItems: z.array(z.string()).optional() - .describe('Navigation item IDs to show in bottom nav (max 5)'), - }).optional().describe('Mobile-specific navigation configuration'), + /** + * REMOVED — fully unimplemented (2026-06 liveness audit: even + * `packages/mobile` ignored it). Re-admit only together with a real mobile + * navigation implementation, per the ADR-0049 trichotomy — a mode picker + * that changes nothing is an authoring trap. + */ + mobileNavigation: retiredKey( + '`App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness ' + + 'audit — fully unimplemented; no renderer, including packages/mobile, ever read ' + + 'it). Delete the key; the block returns if/when a real mobile navigation ships.', + ), /** * Default agent for this app's ambient chat surface. @@ -681,8 +717,15 @@ export const AppSchema = lazySchema(() => z.object({ defaultAgent: SnakeCaseIdentifierSchema.optional() .describe("Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1"), - /** ARIA accessibility attributes */ - aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the application'), + /** + * REMOVED — never read at the APP level (2026-06 liveness audit). ARIA + * attributes are live on the component/widget surfaces that render DOM. + */ + aria: retiredKey( + '`App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no ' + + 'renderer read app-level ARIA attributes). Declare `aria` on the component/widget ' + + 'that renders the DOM node instead. Delete the key.', + ), /** * ADR-0010 §3.7 — Package-level protection envelope. Package diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index 8eaee01b08..10b768f85e 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -37,7 +37,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema - `node_modules/@objectstack/spec/src/ui/app.zod.ts` — Base Navigation Item Schema - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema -- `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol ## How to read these