Skip to content

Commit 88b9d74

Browse files
claude[bot]claude
andauthored
fix(audit): declare sys_activity.type as an open, author-extensible vocabulary (#11659)
* fix(audit): declare sys_activity.type as an open, author-extensible vocabulary (#11507) The select's declared options are the platform BUILT-IN set with documented open-vocabulary semantics — not a closed enum the runtime never enforces. Maintainer ruling 2026-08-24, direction 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * fix(audit): heal the three non-en help leaves left holding a superseded draft (#11507) `--fill=default` is fill-only: the first extract seeded es-ES/ja-JP/zh-CN from an earlier wording of the `sys_activity.type` description, and the re-extract after the wording was revised left those non-empty leaves alone — so the three carried two clauses the declaration no longer makes. Dropped the stale leaves and re-ran the extract config's own documented command; all four now match the source string byte for byte. No hand-edited translation text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 992161b commit 88b9d74

9 files changed

Lines changed: 334 additions & 22 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
Say out loud that `sys_activity.type` is an open, author-extensible vocabulary
6+
— the declared options are the platform's **built-in** set, not a closed enum
7+
8+
An author reading the declaration learned "writing another value will be
9+
rejected". That was false in three independent ways, and the declaration was
10+
the only place that did not say so.
11+
12+
1. Every field on `sys_activity` is `readonly: true`, and `validateRecord`
13+
skips readonly fields on both write branches, so the `invalid_option` check
14+
a `select` normally implies **never runs** on this column.
15+
2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in
16+
the spec and is forwarded verbatim by the audit writer
17+
(`if (milestone.type) activityType = milestone.type`) — a shipped,
18+
documented, author-facing channel straight into the column.
19+
3. An app's own server-side action writes the column directly
20+
(`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this
21+
repository can see those sites.
22+
23+
Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is**
24+
an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and
25+
every closed map over this vocabulary is now the bug. The status quo was the
26+
one option more dangerous than either end state — most of all to an AI writing
27+
metadata, which reads the declaration and believes it.
28+
29+
So the declaration now carries the semantics, in the field's own
30+
`description` — the slot the spec declares for exactly this and, unlike a
31+
source comment, one the contract carries wherever the metadata goes (the
32+
metadata API, the i18n bundles, whatever an author or an AI reads about this
33+
field). No new schema concept was invented: `FieldSchema` has no
34+
open/closed-vocabulary key, and the pin measures that rather than asserting
35+
it, so the day `packages/spec` grows one this declaration is told to move.
36+
37+
Nothing about enforcement changed — that was direction 3 and it was **not**
38+
ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve
39+
values), and both existing vocabulary tests keep every assertion they had.
40+
What changed in them is what a red MEANS: the two cases that used to be filed
41+
as "a defect, characterized — delete these when enforcement lands" now measure
42+
a ruled contract, and say that rejecting an author-contributed value is a
43+
contract change to re-open #11507 over, not a fix to adapt them to.

packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@
3939
* keeps writing, the row keeps landing, every assertion below stays green) and
4040
* red THERE. Breaking a writer is red here and green there. Neither file alone
4141
* covers this object.
42+
*
43+
* ## 2026-08-24 — what the ruling on #11507 changed about this file
44+
*
45+
* Nothing about the measurements; everything about what they MEAN. #8203 wrote
46+
* §3 as "a defect, characterized", with the instruction to delete those cases
47+
* once enforcement landed. The maintainer ruled (direction 4) that this column
48+
* is an OPEN, author-extensible vocabulary: the declared options are the
49+
* platform's BUILT-IN set, ADR-0052 §5b.2 stays a sanctioned write path, and an
50+
* author-contributed value landing verbatim is the contract. So §3 is no longer
51+
* a characterized defect — it is the only end-to-end measurement of the ruled
52+
* behavior, and it stays. See the §3 header for what a red there now means.
4253
*/
4354

4455
import { describe, it, expect } from 'vitest';
@@ -233,8 +244,11 @@ describe('[#8203] sys_activity.type — the writers emit declared values', () =>
233244
expect(
234245
DECLARED_TYPES,
235246
`audit-writers.ts wrote sys_activity.type '${t}', which the object does not `
236-
+ 'declare. Nothing rejects it — the field is readonly, so `validateRecord` '
237-
+ 'skips it — so the row lands and the contract denies it (#8203).',
247+
+ 'declare as a built-in. Nothing rejects it — the field is readonly, so '
248+
+ '`validateRecord` skips it — so the row lands unannounced. The vocabulary is '
249+
+ 'open to AUTHORS (#11507); the platform writing outside its own built-in set '
250+
+ 'is still a finding, because that set is what the platform promises to write, '
251+
+ 'label and offer as a filter. Declare the value, or stop writing it (#8203).',
238252
).toContain(t);
239253
}
240254
});
@@ -297,19 +311,34 @@ describe('[#8203] CONTROL — a writable select rejects the undeclared value', (
297311
});
298312

299313
// ---------------------------------------------------------------------------
300-
// 3. The finding — the identical write is ACCEPTED when the field is readonly
314+
// 3. The ruled contract — an author-contributed value is stored verbatim
301315
// ---------------------------------------------------------------------------
302316

303-
describe('[#8203] the declared vocabulary is unenforceable while the field is readonly', () => {
317+
describe('[#8203/#11507] an author-contributed type is accepted — the open-vocabulary contract', () => {
304318
/**
305-
* ⚠️ These two cases assert a DEFECT, characterized. They are the card's
306-
* observation made mechanical, and they are written to go red the day it is
307-
* fixed — which is the correct signal, not a false alarm.
319+
* ⚠️ These two cases used to be labelled "a DEFECT, characterized", with the
320+
* instruction: go red when enforcement lands, then delete them. That
321+
* instruction is RETIRED, and deleting them now would delete the only
322+
* end-to-end measurement of a ruled contract.
323+
*
324+
* Maintainer ruling, 2026-08-24, #11507 (direction 4): `sys_activity.type` is
325+
* an OPEN, author-extensible vocabulary. The declared options are the
326+
* platform's built-in set; ADR-0052 §5b.2 `activityMilestones[].type` stays a
327+
* sanctioned write path; an author-contributed value landing verbatim is what
328+
* the platform means, not a hole in it. Directions 2 and 3 were considered and
329+
* NOT ruled.
330+
*
331+
* So a red here no longer reads "the fix landed". It reads: something has
332+
* started REJECTING an author-contributed value — which is direction 3, a
333+
* shipped authoring surface turned into a rejection path. Do not adapt these
334+
* cases to it and do not weaken them; re-open #11507, because that is a
335+
* maintainer call and not a test-fixing exercise.
308336
*
309-
* If one fails with "expected 'not_a_declared_type' … received a rejection",
310-
* enforcement has landed (the engine-wide direction #8203 names, in
311-
* `record-validator.ts`). That is the fix: delete these two cases, keep §1
312-
* and the census file, and close #8203.
337+
* The mechanism is unchanged and still worth knowing: every field on this
338+
* object is `readonly`, and `validateRecord` skips readonly fields on both
339+
* branches, so the option check never runs. §2 is the control proving the
340+
* validator runs at all — which is what makes the acceptance below a
341+
* measurement rather than a test that forgot to assert.
313342
*/
314343
it('a direct write of an undeclared type into sys_activity is accepted verbatim', async () => {
315344
const { engine, storeFor } = await boot();
@@ -321,18 +350,22 @@ describe('[#8203] the declared vocabulary is unenforceable while the field is re
321350
expect(DECLARED_TYPES).not.toContain(UNDECLARED);
322351
expect(
323352
activityTypes(storeFor),
324-
'sys_activity.type no longer accepts an undeclared option. If this is because '
325-
+ 'readonly-field option enforcement landed, that is the fix #8203 describes — '
326-
+ 'retire this case and its neighbour and close the card.',
353+
'sys_activity.type no longer accepts an undeclared option. Per the 2026-08-24 '
354+
+ 'ruling on #11507 this column is an OPEN vocabulary: a value outside the '
355+
+ 'built-in set is legitimate and is stored verbatim, so a rejection here is a '
356+
+ 'CONTRACT CHANGE (direction 3, considered and not ruled), not a fix. Re-open '
357+
+ '#11507 instead of adapting this case.',
327358
).toEqual([UNDECLARED]);
328359
});
329360

330361
/**
331-
* The same hole reached through a REAL, shipped authoring surface rather than
362+
* The same path reached through a REAL, shipped authoring surface rather than
332363
* a hand-made insert: `activityMilestones[].type` is `z.string().optional()`
333-
* in the spec, so any metadata author can name any string, and it lands in a
334-
* column whose enum denies it. This is the authoring-time version of the
335-
* defect and the one an AI-written metadata app would hit first.
364+
* in the spec, so any metadata author can name any string and it lands. This
365+
* is the authoring-time face of the open vocabulary, and the one an AI-written
366+
* metadata app meets first — which is exactly why the declaration now says so
367+
* in its own `description` (#11507), instead of showing that author a list
368+
* that reads closed.
336369
*/
337370
it('a milestone declaring an undeclared type writes it — the authoring-surface hole', async () => {
338371
const { engine, storeFor } = await boot();
@@ -342,10 +375,11 @@ describe('[#8203] the declared vocabulary is unenforceable while the field is re
342375
expect(DECLARED_TYPES).not.toContain('escalated_to_legal');
343376
expect(
344377
activityTypes(storeFor),
345-
'a milestone-declared `type` outside the sys_activity.type enum no longer reaches '
346-
+ 'the row. If option enforcement (or a spec-level constraint on '
347-
+ '`activityMilestones[].type`) landed, that is the fix #8203 describes — retire '
348-
+ 'this case and close the card.',
378+
'a milestone-declared `type` outside the built-in sys_activity.type set no longer '
379+
+ 'reaches the row. ADR-0052 §5b.2 is a SANCTIONED author write path and the '
380+
+ '2026-08-24 ruling on #11507 kept it one, so option enforcement here — or a '
381+
+ 'spec-level constraint on `activityMilestones[].type` — breaks shipped author '
382+
+ 'metadata by design. Re-open #11507 before changing this.',
349383
).toEqual(['created', 'escalated_to_legal']);
350384
});
351385
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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

Comments
 (0)