Skip to content

Commit 886ad2c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20922-per-row-dropped-fields
2 parents 99a7547 + 2f2fa11 commit 886ad2c

21 files changed

Lines changed: 1483 additions & 34 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
feat(cli)!: `objectstack validate` and `objectstack build` refuse a field whose `picklist` names no picklist the stack declares, and lint R8 counts `picklist` as an options source (#20825)
7+
8+
Clause-②: no (narrowing — `objectstack validate` / `objectstack build` newly refuse a field `picklist` that names no picklist the stack declares; the R8 change removes a false-positive warning and widens no accept set of its own)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and `Field.picklist` stays the snake_case name it was. What changes is that two authoring commands now refuse one authored shape, a field whose `picklist` names no picklist in the stack. `objectstack migrate meta` could not rewrite that shape even in principle, because which list the author meant is not in the metadata. Nothing here judges a stored row. -->
11+
12+
**BREAKING** — an accept-set narrowing on two authoring commands, shipped as
13+
`minor` under the launch-window convention. A stack with a select field whose
14+
`picklist` names no picklist the stack declares — `picklist: 'industy'` beside a
15+
`picklists: [{ name: 'industry', … }]` — used to pass `objectstack validate` and
16+
`objectstack build` (which wrote the artifact). Both now exit 1 and name the field
17+
and the list it names (`picklist-reference-unknown`).
18+
**One-line fix:** correct `picklist` to a list the stack declares, or declare the
19+
list it names (`picklists: [{ name, label, options }]`, or a `*.picklist.ts` file the
20+
stack imports).
21+
22+
**Which references are judged.** The ones the load path registers: the top-level
23+
`objects` and `objectExtensions` of a one-package stack, or each `packages[]`
24+
entry's own. A reference resolves against every picklist the stack declares,
25+
including one a sibling package in the same artifact owns.
26+
27+
**A list from a package outside the stack is reported, not refused.** When the
28+
package declaring the field lists a `manifest.dependencies` entry the stack does not
29+
carry, the list may live there, and these commands cannot read it. That reference is
30+
an `info` notice (`picklist-reference-unverified`) in `warnings` and on the console,
31+
naming the field, the list and the dependencies — never a failure, not even under
32+
`--strict`.
33+
34+
**Lint R8 (`field/select-missing-options`)** no longer reports a select, multiselect
35+
or radio field that names a `picklist`: the picklist is its options source. The
36+
warning it used to give pointed at `options`, which a field naming a `picklist`
37+
cannot add — the field schema refuses the two together. A select with neither still
38+
warns, and its fix now names both sources as alternatives.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-approvals': minor
3+
---
4+
5+
fix(approvals): a snapshot field the reader is served masked on the data plane is no longer served as stored (#20964)
6+
7+
Clause-②: yes (widening)
8+
9+
The approval payload snapshot is redacted at serve time by the security service's read projection, `getReadableFields`. That projection counts a field whose `maskingRule` applies to the reader as readable, because the data plane serves the column with its value replaced. So the snapshot kept such a field and served it as captured at submission. The redaction now also reads the security contract's `getQueryableFields`, which differs from the read projection by exactly the fields the reader is served masked, and drops those fields, with their derived labels, on both read doors: the approvals inbox reads and the generic data door on the request object. A reader for whom the masking rule is lifted still sees the stored value. The full snapshot stays at rest.
10+
11+
The field is dropped rather than masked. The contract names which fields are masked for a reader, not the masked value, and reproducing the mask in this plugin would be a second copy of the masking rule.
12+
13+
The one public-surface addition is an optional `getQueryableFields(object, context)` member on the field-visibility source that `ApprovalServiceOptions.fieldVisibility` and `ApprovalService.attachFieldVisibility` accept.
14+
15+
A host that constructs `ApprovalService` itself and passes its own `fieldVisibility` source: that source must now also answer `getQueryableFields` (delegate it to the `security` service). A source without it cannot say which readable fields are masked for the reader, so the redaction fails closed and serves no snapshot field. The approvals plugin's own wiring already forwards it.

‎docs/qa/platform-checklist/areas/dashboards.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@
636636
"packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts (#3773)",
637637
"packages/qa/dogfood/test/empty-group-bucket-parity.test.ts (#3839)",
638638
"examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts#dateRange (dateRange + global filter levers)",
639-
"examples/app-showcase/src/data/seed/index.ts#signed_on (prospects carry no signed_on — the deliberate NULL population)"
639+
"examples/app-showcase/src/data/seed/index.ts#accounts (prospects carry no signed_on — the deliberate NULL population; re-pointed #18101: signed_on is a key written INLINE in each seed row, which the shared resolver does not read as a declaration, and accounts is the seed constant that declares those rows)"
640640
],
641641
"history": [
642642
{
@@ -749,7 +749,7 @@
749749
"source": [
750750
"examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts (framework#2501 / objectui#2578 acceptance fixture)",
751751
"examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts",
752-
"examples/app-showcase/src/data/seed/index.ts#sales_region (sales_region + signed_on seeding, prospects unset by design)",
752+
"examples/app-showcase/src/data/seed/index.ts#accounts (sales_region + signed_on seeding, prospects unset by design — re-pointed #18101: both keys are written INLINE in each seed row, which the shared resolver does not read as a declaration, and accounts is the seed constant that declares those rows)",
753753
"examples/app-showcase/src/coverage.ts (dashboard kind notes: revenue-pulse demonstrates dashboard-level filters)",
754754
"objectui packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx (Filter Bindings section — Apply toggle writes false for opt-out, field-override combo, data-testid widget-filter-binding-<name>, patches draft.widgets[i].filterBindings; objectui#2586)",
755755
"objectui packages/plugin-dashboard/src/DashboardFilterBar.tsx (isDirty→Reset affordance RotateCcw, data-testid dashboard-filter-bar; SelectFilter {value,label} options + server GROUP BY via queryDataset, #2578 item 5)",

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@
358358
"packages/spec/src/identity/organization.zod.ts#InvitationSchema (InvitationSchema, InvitationStatus enum)",
359359
"packages/rest/src/rest-route-ledger.ts#REST_ROUTE_LEDGER (GET /api/v1/security/my-delegable-scope — security.describeDelegableScope, ADR-0090 D12 / ADR-0105 D8, self-scoped read half of the delegated-admin gate — re-pointed #18104: describeDelegableScope is a CLIENT METHOD name, carried in this file only inside a string value)",
360360
"packages/spec/src/contracts/security-service.ts#DelegableScope (DelegableScope: isTenantAdmin, scopes, placeableBusinessUnitIds, assignablePositions — no invitation-role field, which is why the scope read cannot be the invite picker's allowlist)",
361-
"packages/spec/src/kernel/public-auth-features.ts#sys_invitation (organization feature gates sys_invitation invite/cancel/resend actions)"
361+
"packages/spec/src/kernel/public-auth-features.ts#PUBLIC_AUTH_FEATURES.organization (organization feature gates sys_invitation invite/cancel/resend actions — re-pointed #18101: sys_invitation is carried in this file only as the HEAD of dotted action names, never as a complete quoted token, and the organization entry of PUBLIC_AUTH_FEATURES is the declaration that holds them)"
362362
],
363363
"history": [
364364
{ "revision": 1, "date": "2026-08-07", "change": "new item: invitation scope gates and lifecycle, pinned to the delegated-admin-invite dogfood test", "ref": "claude/platform-test-checklist-ocwugl" },
@@ -479,7 +479,7 @@
479479
"packages/plugins/plugin-auth/src/auth-route-ledger.ts#BETTER_AUTH_MOUNTED_SURFACE (BETTER_AUTH_MOUNTED_SURFACE admin/* rows: list-users, create-user, set-role, remove-user, revoke-user-session(s))",
480480
"packages/plugins/plugin-auth/src/admin-user-endpoints.ts#resolvePassword (create-user resolvePassword: explicit password wins over generatePassword — #3031/#3033; leaves sys_user + credential sys_account)",
481481
"packages/plugins/plugin-security/src/security-plugin.ts#SecurityPlugin (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it — re-pointed #18104: __referentialFieldClear is a CONTEXT KEY this file only reads off opCtx.context, a member access; the guard that honours it is in the SecurityPlugin class this file declares) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'",
482-
"packages/spec/src/kernel/public-auth-features.ts#sys_user (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0134)",
482+
"packages/spec/src/kernel/public-auth-features.ts#PUBLIC_AUTH_FEATURES.admin (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0134; re-pointed #18101: sys_user is carried in this file only as the HEAD of dotted action names, never as a complete quoted token, and the admin entry of PUBLIC_AUTH_FEATURES is the declaration that holds the lifecycle ones)",
483483
"packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts",
484484
"packages/plugins/plugin-auth/src/anonymous-session-refusal.ts#ANONYMOUS_SESSION_REFUSAL_STATUS (since #17881 an anonymous or revoked /get-session answers 401 with the ADR-0112 refusal envelope, code UNAUTHENTICATED derived from that status; a live session still answers 200 with { user, session }. Both legs are driven end to end in packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts, and the body-not-status discipline for a revoke is kept in packages/plugins/plugin-auth/src/session-of-record.test.ts)"
485485
],

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@
256256
"source": [
257257
"docs/plans/release-15.1-test-plan.md §B4 (#3059)",
258258
"packages/connectors/connector-mcp/src/mcp-provider.ts (#3055 policy: default deny, strict-equality allowlist, both messages)",
259-
"examples/app-showcase/objectstack.config.ts#declarativeStdio (declarativeStdio: ['node'] opt-in) + src/system/connectors/index.ts (the 'remove that option and boot fails loudly — try it' fixture note)"
259+
"examples/app-showcase/objectstack.config.ts#plugins (declarativeStdio: ['node'] opt-in on the ConnectorMcpPlugin entry — re-pointed #18101: declarativeStdio is a key written INLINE in that constructor call, which the shared resolver does not read as a declaration, and the config itself is an anonymous default export, so plugins is the nearest enclosing key it does read) + src/system/connectors/index.ts (the 'remove that option and boot fails loudly — try it' fixture note)"
260260
],
261261
"history": [
262262
{
@@ -1406,7 +1406,7 @@
14061406
"packages/spec/src/shared/external-errors.ts#renderDiffMessage (renderDiffMessage + ExternalSchemaMismatchError — datasource, object, per-column diffs)",
14071407
"packages/cli/src/commands/serve.ts#ExternalDatasourceServicePlugin (ADR-0015 federation block — ExternalDatasourceServicePlugin + createExternalValidationPlugin wired unconditionally, best-effort dynamic import)",
14081408
"packages/spec/src/data/datasource.zod.ts#checkOnBoot (validation policy schema; checkOnBoot default true, the checkonboot/validateonboot alias rows are REJECTION-path did-you-means, not folds — enforced since #13149, so declared = enforced here now)",
1409-
"examples/app-showcase/src/system/datasources/showcase-external.datasource.ts#onMismatch (stock policy onMismatch:'warn' — deliberate, per its own comment)",
1409+
"examples/app-showcase/src/system/datasources/showcase-external.datasource.ts#ShowcaseExternalDatasource (stock policy onMismatch:'warn' — deliberate, per its own comment; re-pointed #18101: onMismatch is a key written INLINE in the external validation literal, which the shared resolver does not read as a declaration, and ShowcaseExternalDatasource is the datasource constant that declares it)",
14101410
"docs/adr/0015-external-datasource-federation.md §5.2"
14111411
],
14121412
"history": [

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,7 +1387,7 @@
13871387
"source": [
13881388
"#3358 §4 ('leaving it unticked on the strength of a label')",
13891389
"ADR-0059",
1390-
"examples/app-showcase/src/ui/actions/index.ts#maxSize (maxSize declarations)"
1390+
"examples/app-showcase/src/ui/actions/index.ts#ActionParamGalleryAction (maxSize declarations on its image and file params — re-pointed #18101: maxSize is a key written INLINE in each param literal, which the shared resolver does not read as a declaration, and ActionParamGalleryAction is the action constant that declares those params)"
13911391
],
13921392
"history": [
13931393
{
@@ -2835,7 +2835,7 @@
28352835
},
28362836
"source": [
28372837
"objectui: packages/fields/src/widgets/LookupField.tsx (allowCreate default-on, SYSTEM_REFERENCE_RX / USER_DIRECTORY_REFS, isUserFacingReference, handleCreateNew), packages/fields/src/widgets/RecordPickerDialog.tsx (scoped find)",
2838-
"examples/app-showcase/src/data/objects/business-unit.object.ts#allowCreate + category.object.ts (parent allowCreate:true)",
2838+
"examples/app-showcase/src/data/objects/business-unit.object.ts#BusinessUnit.parent + category.object.ts (parent allowCreate:true — re-pointed #18101: allowCreate is a key written INLINE in the lookup options, which the shared resolver does not read as a declaration, and the parent field of the BusinessUnit object is the declaration that carries it)",
28392839
"PENDING-GAPS §B lookup-picker-create-new"
28402840
],
28412841
"history": [

‎packages/cli/src/commands/compile.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
6464
// [#20393] The boot registrar's divergent view-container `name` refusal — the
6565
// walk `os validate` step 2c runs, over `@objectstack/objectql`'s one judge.
6666
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
67+
// A field `picklist` that names no picklist the stack declares — the walk
68+
// `os validate` step 2d runs, over the same judge.
69+
import { judgePicklistReferences, printPicklistReferenceNotices } from '../utils/picklist-references.js';
6770

6871
export default class Compile extends Command {
6972
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -178,6 +181,13 @@ export default class Compile extends Command {
178181
// computes the identical record so the residue pin holds, and it reports
179182
// without ever refusing. Appended LAST, in `os validate`'s order.
180183
let jsxGateNotices: ReturnType<typeof resolveJsxGateManifest>['notices'] = [];
184+
// The `info` records of a field `picklist` reference that resolves nowhere
185+
// in the stack while the declaring package depends on packages outside it
186+
// (step 3a-bis). A member of `warningsSoFar()`, ⛔ not a payload key, for
187+
// the reasons the three lists above record: it reports without refusing,
188+
// and `os validate` computes the identical records. Appended LAST, in
189+
// `os validate`'s order.
190+
let picklistReferenceNotices: ReturnType<typeof judgePicklistReferences>['notices'] = [];
181191
const warningsSoFar = () => [
182192
...ruleAdvisories,
183193
...docWarnings,
@@ -186,6 +196,7 @@ export default class Compile extends Command {
186196
...navGroupWarnings,
187197
...permissionSetCollisionWarnings,
188198
...jsxGateNotices,
199+
...picklistReferenceNotices,
189200
];
190201
// [#18780] ONE rendering of the author-time advisory block, from the
191202
// COMPLETE list — hoisted here for the same reason the lists above are.
@@ -448,6 +459,36 @@ export default class Compile extends Command {
448459
this.exit(1);
449460
}
450461

462+
// 3a-bis. A field `picklist` that names no picklist the stack declares is
463+
// REFUSED — the SAME call `os validate` makes at its step 2d, so the
464+
// two doors cannot disagree about which references resolve. Without
465+
// it this door wrote the artifact carrying the misspelt reference, and
466+
// the command that ships shipped a choice with nothing to choose.
467+
//
468+
// A reference the stack cannot resolve while the declaring package
469+
// depends on packages outside the stack is an `info` notice instead
470+
// (see `utils/picklist-references.ts`): the list may live there, and
471+
// this command cannot read it. Printed here, carried in
472+
// `warningsSoFar()`, never gating.
473+
//
474+
// Right after the parse and ahead of every artifact write, like 3a.
475+
// The `--json` face is 3a's envelope (`errors`); the text face is
476+
// `os validate`'s.
477+
const picklistJudgement = judgePicklistReferences(result.data as Record<string, unknown>);
478+
picklistReferenceNotices = [...picklistJudgement.notices];
479+
if (!flags.json) printPicklistReferenceNotices(picklistReferenceNotices);
480+
if (picklistJudgement.refusals.length > 0) {
481+
if (flags.json) {
482+
await emitJson({ success: false, errors: picklistJudgement.refusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
483+
this.exit(1);
484+
}
485+
const n = picklistJudgement.refusals.length;
486+
console.log('');
487+
printError(`A field names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`);
488+
printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY });
489+
this.exit(1);
490+
}
491+
451492
// 3b. The author-time rule registry (#4409) — one table, three commands.
452493
// `os build` was the WEAKEST of the three authoring gates before it:
453494
// it published stacks `os validate` or `os lint` refuse, because the

0 commit comments

Comments
 (0)