|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { ObjectSchema } from '@objectstack/spec/data'; |
| 5 | +import { SysActivity } from './index.js'; |
| 6 | + |
| 7 | +/** |
| 8 | + * #11507 — the declaration of `sys_activity.type` must say what the column |
| 9 | + * actually is: an OPEN, author-extensible vocabulary whose declared options are |
| 10 | + * the platform's BUILT-IN set. |
| 11 | + * |
| 12 | + * ## The ruling this file executes |
| 13 | + * |
| 14 | + * Maintainer, 2026-08-24, on #11507 (direction 4 of the four the card framed), |
| 15 | + * verbatim: 「四维分析一致的,接手你的建议。」 Recorded on the card as: |
| 16 | + * |
| 17 | + * > the column is an open, author-extensible vocabulary. […] make the |
| 18 | + * > declaration honest (the select's declared options become the built-in set |
| 19 | + * > with documented open-vocabulary semantics — not a closed enum the runtime |
| 20 | + * > never enforces); ADR-0052 §5b.2 stays a sanctioned write path […] |
| 21 | + * > Downstream: every closed map over this vocabulary is now the bug. |
| 22 | + * |
| 23 | + * Directions 2 (rule the producer non-conformant) and 3 (enforce the vocabulary) |
| 24 | + * were NOT ruled. Nothing here should be read as a step toward either. |
| 25 | + * |
| 26 | + * ## Why the declaration was dishonest, in one paragraph |
| 27 | + * |
| 28 | + * Three things were true at once. The field is a `select` over a fixed list — |
| 29 | + * which normally means "anything else is `invalid_option`". Every field on this |
| 30 | + * object is `readonly: true` and `validateRecord` skips readonly fields on both |
| 31 | + * write branches, so that check never runs. And ADR-0052 §5b.2's |
| 32 | + * `activityMilestones[].type` (`z.string().optional()` in `object.zod.ts`) is a |
| 33 | + * shipped, documented, author-facing channel that forwards ANY string into the |
| 34 | + * column — `audit-writers.ts`: `if (milestone.type) activityType = milestone.type`. |
| 35 | + * An author (a human, and far more often an AI writing metadata) who reads the |
| 36 | + * declaration builds the model "writing another value will be rejected", and |
| 37 | + * that model is false. The status quo was more dangerous than either end state, |
| 38 | + * which is what the four-facet analysis said and what the ruling adopted. |
| 39 | + * |
| 40 | + * ## The mechanism, and why this one |
| 41 | + * |
| 42 | + * `FieldSchema` has no key that means "open vocabulary" — no `openVocabulary`, |
| 43 | + * no `restricted`, no `allowCustomValues` (measured below, third case, so the |
| 44 | + * next author does not have to guess). Adding one is a `packages/spec` change |
| 45 | + * and therefore a different seat's card, not something to invent here. The slot |
| 46 | + * the spec DOES declare for exactly this is the field's own `description` |
| 47 | + * ("Tooltip/Help text", `field.zod.ts` — the documentation slot, distinct from |
| 48 | + * `placeholder` and `inlineHelpText`), and it is carried BY THE CONTRACT: the |
| 49 | + * exported `SysActivity` is the output of `ObjectSchema.create()`, i.e. of a |
| 50 | + * real parse, so what this file reads is metadata that ships — to the metadata |
| 51 | + * API, to the i18n bundles, to whatever an author or an AI reads about this |
| 52 | + * field — and not a source comment that stops at the file boundary. |
| 53 | + * |
| 54 | + * So: the source docblock carries the reasoning, and the `description` carries |
| 55 | + * the contract. This file pins the second, because only the second travels. |
| 56 | + */ |
| 57 | + |
| 58 | +/** The `type` field as it is actually declared (post-parse). */ |
| 59 | +function typeField(): { type?: string; description?: unknown; options?: unknown } { |
| 60 | + return ((SysActivity as { fields?: Record<string, Record<string, unknown>> }) |
| 61 | + .fields?.type ?? {}) as { type?: string; description?: unknown; options?: unknown }; |
| 62 | +} |
| 63 | + |
| 64 | +/** Option values declared by the `type` select field. */ |
| 65 | +function typeValues(): string[] { |
| 66 | + const options = (typeField().options ?? []) as Array<string | { value?: string }>; |
| 67 | + return options.map((o) => (typeof o === 'string' ? o : String(o.value))); |
| 68 | +} |
| 69 | + |
| 70 | +describe('[#11507] sys_activity.type is an OPEN vocabulary and the declaration says so', () => { |
| 71 | + /** |
| 72 | + * The half of the ruling that is easy to lose: "open" does NOT mean |
| 73 | + * "undeclared". The declared options are the BUILT-IN set — the values the |
| 74 | + * platform itself writes and the values a picker/filter offers — and they |
| 75 | + * stay declared. A future author who reads "open vocabulary" and deletes the |
| 76 | + * option list would take the built-in set, the labels, the i18n leaves and |
| 77 | + * the census pin with it. |
| 78 | + */ |
| 79 | + it('keeps a declared built-in set — an open vocabulary is not an absent one', () => { |
| 80 | + const field = typeField(); |
| 81 | + expect( |
| 82 | + field.type, |
| 83 | + 'sys_activity.type stopped being a `select`. The #11507 ruling made the vocabulary ' |
| 84 | + + 'OPEN, not undeclared: the declared options are the platform built-in set and ' |
| 85 | + + 'they stay. Widening the column to a bare `text` deletes the built-in set, its ' |
| 86 | + + 'labels and its i18n leaves, and leaves authors nothing to extend FROM.', |
| 87 | + ).toBe('select'); |
| 88 | + expect( |
| 89 | + typeValues().length, |
| 90 | + 'sys_activity.type declares no options. See above: open ≠ undeclared (#11507).', |
| 91 | + ).toBeGreaterThan(0); |
| 92 | + }); |
| 93 | + |
| 94 | + /** |
| 95 | + * The load-bearing assertion, and the deliverable of #11507. The three |
| 96 | + * markers are the three things an author must be able to learn FROM THE |
| 97 | + * DECLARATION ITSELF: |
| 98 | + * - the declared list is the BUILT-IN set (not the whole legal set); |
| 99 | + * - the vocabulary is OPEN (an author may contribute a value); |
| 100 | + * - the sanctioned way to do that is ADR-0052 §5b.2, which stays a write |
| 101 | + * path per the ruling — not a rejection path. |
| 102 | + * |
| 103 | + * Asserted as markers rather than as an exact string: the wording is meant to |
| 104 | + * be improvable, the three facts are not. |
| 105 | + */ |
| 106 | + it('declares open-vocabulary semantics in the CONTRACT, not only in a source comment', () => { |
| 107 | + const description = typeField().description; |
| 108 | + const hint = |
| 109 | + 'sys_activity.type carries no open-vocabulary documentation in its declaration. ' |
| 110 | + + 'Per the 2026-08-24 maintainer ruling on #11507 this column is an OPEN, ' |
| 111 | + + 'author-extensible vocabulary: the declared options are the BUILT-IN set, an ' |
| 112 | + + 'author-contributed value (ADR-0052 §5b.2 `activityMilestones[].type`, or an ' |
| 113 | + + "app action's own `insert`) is legitimate, and it is stored verbatim — nothing " |
| 114 | + + 'rejects it, because every field here is `readonly` and `validateRecord` skips ' |
| 115 | + + 'readonly fields. A bare option list without that sentence tells an author — ' |
| 116 | + + 'most often an AI writing metadata — that another value would be REJECTED, ' |
| 117 | + + 'which is false. Put it back in `description` (the contract carries it; a ' |
| 118 | + + 'source comment does not).'; |
| 119 | + |
| 120 | + expect(typeof description, hint).toBe('string'); |
| 121 | + const text = String(description); |
| 122 | + expect(text.length, hint).toBeGreaterThan(0); |
| 123 | + for (const marker of [/built-in/i, /open vocabulary/i, /ADR-0052/]) { |
| 124 | + expect(marker.test(text), `${hint}\nMissing from the description: ${marker}`).toBe(true); |
| 125 | + } |
| 126 | + }); |
| 127 | + |
| 128 | + /** |
| 129 | + * WHY the mechanism above is prose in `description` rather than a declared |
| 130 | + * flag: there is no flag. Measured, not assumed — and written so it goes RED |
| 131 | + * the day the spec grows one, which is the day this declaration should move |
| 132 | + * the semantics into it (and the day the objectui-side consumer can read the |
| 133 | + * openness mechanically instead of being told). |
| 134 | + * |
| 135 | + * Note what this does NOT claim: that such a key should not exist. Declaring |
| 136 | + * one is a `packages/spec` decision and belongs to the spec seat. |
| 137 | + */ |
| 138 | + it('has no declared spec key for open/closed vocabulary — `description` is the available slot', () => { |
| 139 | + const probes = ['openVocabulary', 'restricted', 'allowCustomValues', 'extensible']; |
| 140 | + for (const key of probes) { |
| 141 | + const candidate = JSON.parse(JSON.stringify(SysActivity)) as { |
| 142 | + fields: Record<string, Record<string, unknown>>; |
| 143 | + }; |
| 144 | + candidate.fields.type[key] = true; |
| 145 | + const parsed = ObjectSchema.safeParse(candidate); |
| 146 | + expect( |
| 147 | + parsed.success, |
| 148 | + `FieldSchema now accepts \`${key}\` on a field. If \`packages/spec\` grew a real ` |
| 149 | + + 'open/closed-vocabulary declaration, this file is the pin that says so: move ' |
| 150 | + + "sys_activity.type's open-vocabulary semantics onto that key (keeping the " |
| 151 | + + 'description as help text), and tell the objectui consumer card — a machine-' |
| 152 | + + 'readable flag is what lets a renderer stop guessing (#11507).', |
| 153 | + ).toBe(false); |
| 154 | + } |
| 155 | + }); |
| 156 | +}); |
0 commit comments