Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/sys-activity-type-open-vocabulary.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@objectstack/plugin-audit': patch
---

Say out loud that `sys_activity.type` is an open, author-extensible vocabulary
— the declared options are the platform's **built-in** set, not a closed enum

An author reading the declaration learned "writing another value will be
rejected". That was false in three independent ways, and the declaration was
the only place that did not say so.

1. Every field on `sys_activity` is `readonly: true`, and `validateRecord`
skips readonly fields on both write branches, so the `invalid_option` check
a `select` normally implies **never runs** on this column.
2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in
the spec and is forwarded verbatim by the audit writer
(`if (milestone.type) activityType = milestone.type`) — a shipped,
documented, author-facing channel straight into the column.
3. An app's own server-side action writes the column directly
(`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this
repository can see those sites.

Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is**
an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and
every closed map over this vocabulary is now the bug. The status quo was the
one option more dangerous than either end state — most of all to an AI writing
metadata, which reads the declaration and believes it.

So the declaration now carries the semantics, in the field's own
`description` — the slot the spec declares for exactly this and, unlike a
source comment, one the contract carries wherever the metadata goes (the
metadata API, the i18n bundles, whatever an author or an AI reads about this
field). No new schema concept was invented: `FieldSchema` has no
open/closed-vocabulary key, and the pin measures that rather than asserting
it, so the day `packages/spec` grows one this declaration is told to move.

Nothing about enforcement changed — that was direction 3 and it was **not**
ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve
values), and both existing vocabulary tests keep every assertion they had.
What changed in them is what a red MEANS: the two cases that used to be filed
as "a defect, characterized — delete these when enforcement lands" now measure
a ruled contract, and say that rejecting an author-contributed value is a
contract change to re-open #11507 over, not a fix to adapt them to.
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,17 @@
* keeps writing, the row keeps landing, every assertion below stays green) and
* red THERE. Breaking a writer is red here and green there. Neither file alone
* covers this object.
*
* ## 2026-08-24 — what the ruling on #11507 changed about this file
*
* Nothing about the measurements; everything about what they MEAN. #8203 wrote
* §3 as "a defect, characterized", with the instruction to delete those cases
* once enforcement landed. The maintainer ruled (direction 4) that this column
* is an OPEN, author-extensible vocabulary: the declared options are the
* platform's BUILT-IN set, ADR-0052 §5b.2 stays a sanctioned write path, and an
* author-contributed value landing verbatim is the contract. So §3 is no longer
* a characterized defect — it is the only end-to-end measurement of the ruled
* behavior, and it stays. See the §3 header for what a red there now means.
*/

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

// ---------------------------------------------------------------------------
// 3. The finding — the identical write is ACCEPTED when the field is readonly
// 3. The ruled contract — an author-contributed value is stored verbatim
// ---------------------------------------------------------------------------

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

/**
* The same hole reached through a REAL, shipped authoring surface rather than
* The same path reached through a REAL, shipped authoring surface rather than
* a hand-made insert: `activityMilestones[].type` is `z.string().optional()`
* in the spec, so any metadata author can name any string, and it lands in a
* column whose enum denies it. This is the authoring-time version of the
* defect and the one an AI-written metadata app would hit first.
* in the spec, so any metadata author can name any string and it lands. This
* is the authoring-time face of the open vocabulary, and the one an AI-written
* metadata app meets first — which is exactly why the declaration now says so
* in its own `description` (#11507), instead of showing that author a list
* that reads closed.
*/
it('a milestone declaring an undeclared type writes it — the authoring-surface hole', async () => {
const { engine, storeFor } = await boot();
Expand All@@ -342,10 +375,11 @@ describe('[#8203] the declared vocabulary is unenforceable while the field is re
expect(DECLARED_TYPES).not.toContain('escalated_to_legal');
expect(
activityTypes(storeFor),
'a milestone-declared `type` outside the sys_activity.type enum no longer reaches '
+ 'the row. If option enforcement (or a spec-level constraint on '
+ '`activityMilestones[].type`) landed, that is the fix #8203 describes — retire '
+ 'this case and close the card.',
'a milestone-declared `type` outside the built-in sys_activity.type set no longer '
+ 'reaches the row. ADR-0052 §5b.2 is a SANCTIONED author write path and the '
+ '2026-08-24 ruling on #11507 kept it one, so option enforcement here — or a '
+ 'spec-level constraint on `activityMilestones[].type` — breaks shipped author '
+ 'metadata by design. Re-open #11507 before changing this.',
).toEqual(['created', 'escalated_to_legal']);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { ObjectSchema } from '@objectstack/spec/data';
import { SysActivity } from './index.js';

/**
* #11507 — the declaration of `sys_activity.type` must say what the column
* actually is: an OPEN, author-extensible vocabulary whose declared options are
* the platform's BUILT-IN set.
*
* ## The ruling this file executes
*
* Maintainer, 2026-08-24, on #11507 (direction 4 of the four the card framed),
* verbatim: 「四维分析一致的,接手你的建议。」 Recorded on the card as:
*
* > the column is an open, author-extensible vocabulary. […] make the
* > declaration honest (the select's declared options become the built-in set
* > with documented open-vocabulary semantics — not a closed enum the runtime
* > never enforces); ADR-0052 §5b.2 stays a sanctioned write path […]
* > Downstream: every closed map over this vocabulary is now the bug.
*
* Directions 2 (rule the producer non-conformant) and 3 (enforce the vocabulary)
* were NOT ruled. Nothing here should be read as a step toward either.
*
* ## Why the declaration was dishonest, in one paragraph
*
* Three things were true at once. The field is a `select` over a fixed list —
* which normally means "anything else is `invalid_option`". Every field on this
* object is `readonly: true` and `validateRecord` skips readonly fields on both
* write branches, so that check never runs. And ADR-0052 §5b.2's
* `activityMilestones[].type` (`z.string().optional()` in `object.zod.ts`) is a
* shipped, documented, author-facing channel that forwards ANY string into the
* column — `audit-writers.ts`: `if (milestone.type) activityType = milestone.type`.
* An author (a human, and far more often an AI writing metadata) who reads the
* declaration builds the model "writing another value will be rejected", and
* that model is false. The status quo was more dangerous than either end state,
* which is what the four-facet analysis said and what the ruling adopted.
*
* ## The mechanism, and why this one
*
* `FieldSchema` has no key that means "open vocabulary" — no `openVocabulary`,
* no `restricted`, no `allowCustomValues` (measured below, third case, so the
* next author does not have to guess). Adding one is a `packages/spec` change
* and therefore a different seat's card, not something to invent here. The slot
* the spec DOES declare for exactly this is the field's own `description`
* ("Tooltip/Help text", `field.zod.ts` — the documentation slot, distinct from
* `placeholder` and `inlineHelpText`), and it is carried BY THE CONTRACT: the
* exported `SysActivity` is the output of `ObjectSchema.create()`, i.e. of a
* real parse, so what this file reads is metadata that ships — to the metadata
* API, to the i18n bundles, to whatever an author or an AI reads about this
* field — and not a source comment that stops at the file boundary.
*
* So: the source docblock carries the reasoning, and the `description` carries
* the contract. This file pins the second, because only the second travels.
*/

/** The `type` field as it is actually declared (post-parse). */
function typeField(): { type?: string; description?: unknown; options?: unknown } {
return ((SysActivity as { fields?: Record<string, Record<string, unknown>> })
.fields?.type ?? {}) as { type?: string; description?: unknown; options?: unknown };
}

/** Option values declared by the `type` select field. */
function typeValues(): string[] {
const options = (typeField().options ?? []) as Array<string | { value?: string }>;
return options.map((o) => (typeof o === 'string' ? o : String(o.value)));
}

describe('[#11507] sys_activity.type is an OPEN vocabulary and the declaration says so', () => {
/**
* The half of the ruling that is easy to lose: "open" does NOT mean
* "undeclared". The declared options are the BUILT-IN set — the values the
* platform itself writes and the values a picker/filter offers — and they
* stay declared. A future author who reads "open vocabulary" and deletes the
* option list would take the built-in set, the labels, the i18n leaves and
* the census pin with it.
*/
it('keeps a declared built-in set — an open vocabulary is not an absent one', () => {
const field = typeField();
expect(
field.type,
'sys_activity.type stopped being a `select`. The #11507 ruling made the vocabulary '
+ 'OPEN, not undeclared: the declared options are the platform built-in set and '
+ 'they stay. Widening the column to a bare `text` deletes the built-in set, its '
+ 'labels and its i18n leaves, and leaves authors nothing to extend FROM.',
).toBe('select');
expect(
typeValues().length,
'sys_activity.type declares no options. See above: open ≠ undeclared (#11507).',
).toBeGreaterThan(0);
});

/**
* The load-bearing assertion, and the deliverable of #11507. The three
* markers are the three things an author must be able to learn FROM THE
* DECLARATION ITSELF:
* - the declared list is the BUILT-IN set (not the whole legal set);
* - the vocabulary is OPEN (an author may contribute a value);
* - the sanctioned way to do that is ADR-0052 §5b.2, which stays a write
* path per the ruling — not a rejection path.
*
* Asserted as markers rather than as an exact string: the wording is meant to
* be improvable, the three facts are not.
*/
it('declares open-vocabulary semantics in the CONTRACT, not only in a source comment', () => {
const description = typeField().description;
const hint =
'sys_activity.type carries no open-vocabulary documentation in its declaration. '
+ 'Per the 2026-08-24 maintainer ruling on #11507 this column is an OPEN, '
+ 'author-extensible vocabulary: the declared options are the BUILT-IN set, an '
+ 'author-contributed value (ADR-0052 §5b.2 `activityMilestones[].type`, or an '
+ "app action's own `insert`) is legitimate, and it is stored verbatim — nothing "
+ 'rejects it, because every field here is `readonly` and `validateRecord` skips '
+ 'readonly fields. A bare option list without that sentence tells an author — '
+ 'most often an AI writing metadata — that another value would be REJECTED, '
+ 'which is false. Put it back in `description` (the contract carries it; a '
+ 'source comment does not).';

expect(typeof description, hint).toBe('string');
const text = String(description);
expect(text.length, hint).toBeGreaterThan(0);
for (const marker of [/built-in/i, /open vocabulary/i, /ADR-0052/]) {
expect(marker.test(text), `${hint}\nMissing from the description: ${marker}`).toBe(true);
}
});

/**
* WHY the mechanism above is prose in `description` rather than a declared
* flag: there is no flag. Measured, not assumed — and written so it goes RED
* the day the spec grows one, which is the day this declaration should move
* the semantics into it (and the day the objectui-side consumer can read the
* openness mechanically instead of being told).
*
* Note what this does NOT claim: that such a key should not exist. Declaring
* one is a `packages/spec` decision and belongs to the spec seat.
*/
it('has no declared spec key for open/closed vocabulary — `description` is the available slot', () => {
const probes = ['openVocabulary', 'restricted', 'allowCustomValues', 'extensible'];
for (const key of probes) {
const candidate = JSON.parse(JSON.stringify(SysActivity)) as {
fields: Record<string, Record<string, unknown>>;
};
candidate.fields.type[key] = true;
const parsed = ObjectSchema.safeParse(candidate);
expect(
parsed.success,
`FieldSchema now accepts \`${key}\` on a field. If \`packages/spec\` grew a real `
+ 'open/closed-vocabulary declaration, this file is the pin that says so: move '
+ "sys_activity.type's open-vocabulary semantics onto that key (keeping the "
+ 'description as help text), and tell the objectui consumer card — a machine-'
+ 'readable flag is what lets a renderer stop guessing (#11507).',
).toBe(false);
}
});
});
Loading
Loading