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
5 changes: 5 additions & 0 deletions .changeset/fifth-population-spec-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/spec": patch
---

Customer-facing refusal and warning messages built inside plain `function` declarations (and two hoisted ruling consts) no longer cite internal tracker ids. The teaching stays; customer-resolvable anchors stay — ADR ids, protocol versions, error codes such as `INVALID_FILTER / 400`, and ruling dates — while the `#NNNN` tokens, which resolve to nothing for a refused author, are gone. The doc-authoring gate now recognises function declarations as text sinks (its fifth population, with its own blindness floor) and prints its own scan boundary, so the next boundary move is visible from the gate's output.
5 changes: 5 additions & 0 deletions .changeset/hot-reload-refusal-id-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/core": patch
---

`HotReloadManager`'s refusal messages — the plugin-registration doors for retired `stateStrategy` values and removed config keys, and the `startWatching()` removal notice — no longer cite internal tracker ids. The prescriptions keep their customer-resolvable anchors (ADR-0049 enforce-or-remove, the `@objectstack/spec` / `@objectstack/core` versions, and the `scheduleReload` migration call); the `#NNNN` tokens, which resolve to nothing for the host author reading the refusal, are gone.
25 changes: 16 additions & 9 deletions packages/core/src/hot-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,14 @@ describe('[#12340] stateStrategy refusal', () => {
// doors (parse vs registration) and are deliberately not shared.
const m = caught?.message ?? '';
expect(m).toContain(retired);
expect(m).toContain('#12340');
expect(m).toContain('ADR-0049');
expect(m).toContain('ADR-0049 enforce-or-remove');
expect(m).toContain('were removed');
expect(m).toContain("Use 'memory'");
expect(m).toContain('p'); // locates the offending plugin
// The negative twin (#13179's strip): the prescription anchors on the
// ADR and the version — never on a tracker id the refused author
// cannot resolve. Mirrors the spec-side door's own pin.
expect(m).not.toMatch(/(?<![#&])#\d{3,5}(?![0-9A-Za-z])/);
});
}

Expand Down Expand Up @@ -299,15 +302,16 @@ describe('[#12340] stateStrategy refusal', () => {
expect(caught?.code).toBe('VALIDATION_ERROR');
expect(caught?.status).toBe(400);
expect(caught?.message).toContain('distributedConfig');
expect(caught?.message).toContain('#12340');
expect(caught?.message).toContain('ADR-0049 enforce-or-remove');
expect(caught?.message).toContain('nothing ever read it');
expect(caught?.message).not.toMatch(/(?<![#&])#\d{3,5}(?![0-9A-Za-z])/);
});

it('refuses even when hot reload is disabled', () => {
// The door must not depend on `enabled`: a false declaration is false
// whether or not the feature is switched on.
const cfg = { ...configWith('disk'), enabled: false } as HotReloadConfigParsed;
expect(() => mgr.registerPlugin('p', cfg)).toThrow(/#12340/);
expect(() => mgr.registerPlugin('p', cfg)).toThrow(/were removed/);
});

for (const live of ['memory', 'none'] as const) {
Expand Down Expand Up @@ -377,18 +381,20 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => {
// The prescription's load-bearing facts, by CONTENT — this message is the
// whole migration document for whoever hits it.
const m = caught?.message ?? '';
expect(m).toContain('#12428');
expect(m).toContain('ADR-0049');
expect(m).toContain('ADR-0049 enforce-or-remove');
expect(m).toContain('never watched');
expect(m).toContain('scheduleReload');
expect(m).toContain('p'); // locates the offending plugin
// The negative twin (#13179's strip, extended to this door's sibling id):
// anchored on the ADR and the migration call, never on a tracker id.
expect(m).not.toMatch(/(?<![#&])#\d{3,5}(?![0-9A-Za-z])/);
});

it('refuses startWatching for an UNREGISTERED plugin too', () => {
// The old body early-returned when the plugin was unknown or disabled, so
// the lie was conditional. The refusal must not be: the method never
// worked for anyone, in any state.
expect(() => mgr.startWatching('never-registered')).toThrow(/#12428/);
expect(() => mgr.startWatching('never-registered')).toThrow(/never watched/);
});

it('refuses a leftover watchPatterns at registration', () => {
Expand All @@ -406,16 +412,17 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => {
expect(caught?.code).toBe('VALIDATION_ERROR');
expect(caught?.status).toBe(400);
expect(caught?.message).toContain('watchPatterns');
expect(caught?.message).toContain('#12428');
expect(caught?.message).toContain('ADR-0049 enforce-or-remove');
expect(caught?.message).toContain('nothing ever read it');
expect(caught?.message).not.toMatch(/(?<![#&])#\d{3,5}(?![0-9A-Za-z])/);
});

it('refuses watchPatterns even when hot reload is disabled', () => {
// The door must not depend on `enabled` — a false declaration is false
// whether or not the feature is switched on.
expect(() =>
mgr.registerPlugin('p', liveConfig({ enabled: false, watchPatterns: ['a/**'] }))
).toThrow(/#12428/);
).toThrow(/nothing ever read it/);
});

it('still registers a config that does not carry the retired key', () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/hot-reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const HONOURED_STATE_STRATEGIES = ['memory', 'none'] as const;
*/
const RETIRED_STATE_STRATEGY_GUIDANCE =
"'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in "
+ '@objectstack/spec 18 (#12340, ADR-0049 enforce-or-remove) — neither was ever '
+ '@objectstack/spec 18 (ADR-0049 enforce-or-remove) — neither was ever '
+ "implemented. Both wrote to the same in-memory Map as 'memory' and reported it "
+ 'only at debug level, so a host that asked for durable or cluster-replicated '
+ 'state got process-local memory and no error. '
Expand Down Expand Up @@ -111,7 +111,7 @@ const RETIRED_HOT_RELOAD_KEYS: ReadonlyArray<readonly [string, string]> = [
[
'distributedConfig',
"'distributedConfig' was removed from "
+ 'HotReloadConfig in @objectstack/spec 18 (#12340, ADR-0049 '
+ 'HotReloadConfig in @objectstack/spec 18 (ADR-0049 '
+ 'enforce-or-remove) — nothing ever read it. A provider, endpoints, a key '
+ 'prefix, a TTL and a replication factor could all be declared and no '
+ "connection was ever opened. It left with the stateStrategy: 'distributed' "
Expand All @@ -121,7 +121,7 @@ const RETIRED_HOT_RELOAD_KEYS: ReadonlyArray<readonly [string, string]> = [
[
'watchPatterns',
"'watchPatterns' was removed from HotReloadConfig in @objectstack/spec 18 "
+ '(#12428, ADR-0049 enforce-or-remove) — nothing ever read it. Its only two '
+ '(ADR-0049 enforce-or-remove) — nothing ever read it. Its only two '
+ 'uses were log lines: no watcher was ever constructed from it, so an author '
+ 'could declare a glob and no file change ever triggered a reload. File '
+ 'watching is the HOST\'s job in this host-driven library. Delete the key, '
Expand Down Expand Up @@ -322,7 +322,7 @@ export class HotReloadManager {
startWatching(pluginName: string): never {
throw hotReloadRefusal(
`[HotReload] Plugin '${pluginName}': startWatching() never watched `
+ 'anything and was removed in @objectstack/core 18 (#12428, ADR-0049 '
+ 'anything and was removed in @objectstack/core 18 (ADR-0049 '
+ "enforce-or-remove). It logged 'File watching started' at info level "
+ 'while no watcher was ever constructed, so no file change could ever '
+ 'trigger a reload. File watching is the HOST\'s job in this '
Expand Down
12 changes: 7 additions & 5 deletions packages/spec/src/compose-stacks-key-loss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe('#5005 rule 2 — conflicting values throw a prescriptive error', () =>
// ─── Rule 3 — unhandled keys warn ───────────────────────────────────

describe('#5005 rule 3 — a key with no declared rule warns', () => {
it('warns once, names the key and points at #5005', () => {
it('warns once, names the key and carries the declare-a-rule prescription', () => {
// A key the schema does not declare: reaches composeStacks only via
// `strict: false`, which is exactly how a NEW key looks before someone
// remembers to teach the composer about it.
Expand All @@ -167,11 +167,13 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => {

const composed = composeStacks([a, b]) as Record<string, unknown>;

// One warning that both names the key AND points at #5005 — not two
// unrelated ones (`defineStack` also warns about undeclared keys).
// One warning that both names the key AND carries the prescription — not
// two unrelated ones (`defineStack` also warns about undeclared keys).
// Anchored on the prescription's own words, never on a tracker id the
// author cannot resolve (#13156's strip).
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
expect(
warnings.some((w) => w.includes('composeStacks') && w.includes("'futureThing'") && w.includes('#5005')),
warnings.some((w) => w.includes('composeStacks') && w.includes("'futureThing'") && w.includes('COMPOSE_KEY_DISPOSITIONS')),
).toBe(true);
// …and it is composed by the default rule rather than dropped.
expect(composed.futureThing).toEqual({ enabled: true });
Expand All @@ -195,7 +197,7 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => {
expect(
warnSpy.mock.calls
.map((c) => String(c[0]))
.some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('#5005')),
.some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('cannot be composed')),
).toBe(true);
});

Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/data/date-range-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ describe('date-range preset vocabulary (#4614, re-homed by #8793)', () => {
expect(message).toContain('$gte'); // the position it sat in
expect(message).toContain('{30_days_ago}'); // the spelling that works
expect(message).toContain('2026-01-15'); // the ISO alternative
expect(message).toContain('#8793'); // attributable from the error alone
// Attributable from the error alone by the customer-resolvable sentence —
// never by a tracker id (#13156's strip).
expect(message).toContain('Refused at authoring time so the error surfaces where the filter is written.');
expect(message).not.toMatch(/(?<![#&])#\d{3,5}(?![0-9A-Za-z])/);
// A calendar preset prescribes its window pair.
const window = bareDateRangePresetComparandMessage('this_week', '$lt');
expect(window).toContain('{week_start}');
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/data/date-range-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ export function bareDateRangePresetComparandMessage(
+ `understood by the dashboard date-filter positions (dateRange.defaultRange, a date `
+ `global filter's defaultValue), where the console lowers it to {date-macro} bounds `
+ `before querying. As a bare "${operator}" comparand nothing resolves it: a declared `
+ `datetime/date field refuses the query at the engine (INVALID_FILTER / 400, #8690), `
+ `datetime/date field refuses the query at the engine (INVALID_FILTER / 400), `
+ `and any other column compares the literal string. Write the date-macro window `
+ `instead — e.g. ${window} — or an ISO date such as "2026-01-15". `
+ `Refused at authoring time so the error surfaces where the filter is written (#8793).`
+ `Refused at authoring time so the error surfaces where the filter is written.`
);
}
2 changes: 1 addition & 1 deletion packages/spec/src/data/default-value-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export function defaultValueTokenIssue(
`Field "${name}" (${def.type}): the default ${JSON.stringify(dv)} is the runtime token \`current_user\` — `
+ `resolved by the engine to the acting user's \`sys_user.id\` at insert time — and a \`${def.type}\` field `
+ `cannot hold one${lookupAside}. It is legal only on a \`user\` field or a \`lookup\` with `
+ "`reference: 'sys_user'` (#4560 records what happens when that id lands anywhere else). Use one of "
+ "`reference: 'sys_user'` — anywhere else the resolved id is dead data at best. Use one of "
+ 'those, or write a literal record id.'
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,7 @@ describe('FieldSchema', () => {
const issue = r.error.issues.find((i) => i.message.includes('last_30_days'));
expect(issue, 'the preset refusal must surface through relatedListFilter').toBeTruthy();
expect(issue!.path).toEqual(['relatedListFilter', 'created_at', '$gte']);
expect(issue!.message).toContain('#8793');
expect(issue!.message).toContain('Refused at authoring time so the error surfaces where the filter is written.');
}
});

Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/data/filter-comparand-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ function nonListComparandError(
`"${op}" tests membership of a list — write ${shapePreview([value])} for a single value` +
(alternative ? `, or use ${alternative} to compare against it` : '') +
`. Authoring spellings: ${spellings.join(', ')}. The filter was NOT applied, and an ` +
`unapplied filter would have returned the UNFILTERED result set (#5869).`,
`unapplied filter would have returned the UNFILTERED result set.`,
);
}

Expand All @@ -247,7 +247,7 @@ function malformedRangeComparandError(
`value array. Received ${describeOperand(value)} (${shapePreview(value)}) at ${path}. ` +
`A range needs exactly two bounds, in order; the authoring spelling that lowers to ` +
`"$between" is "between". The filter was NOT applied, and an unapplied filter would have ` +
`returned the UNFILTERED result set (#5869).`,
`returned the UNFILTERED result set.`,
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/filter-preset-comparand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('[#8793] FilterConditionSchema — bare preset names in ordering compar
expect(issue.path).toEqual(['created_at', op]);
expect(issue.message).toContain(preset);
expect(issue.message).toContain('PRESET');
expect(issue.message).toContain('#8793');
expect(issue.message).toContain('Refused at authoring time so the error surfaces where the filter is written.');
}
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ describe('RangeOperatorSchema', () => {
// generic "Invalid input" zod gives an unexplained union.
expect(withReference.error?.issues[0]?.code).toBe('invalid_union');
expect(withReference.error?.issues[0]?.message).toContain('Not a valid $and member');
expect(alreadyInvalidComparand.error?.issues[0]?.message).toContain('#7711');
expect(alreadyInvalidComparand.error?.issues[0]?.message).toContain('Declared = enforced (ADR-0049)');
// And the level that DOES judge comparands rejects both — unchanged.
expect(FieldOperatorsSchema.safeParse({ $between: [{ $field: 'budget' }, 100] }).success)
.toBe(false);
Expand Down
9 changes: 7 additions & 2 deletions packages/spec/src/data/filter.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ function listPositionFieldReferenceMessage(position: string): string {
+ 'both SQL drivers refuse the position with INVALID_FILTER / 400. Write a literal value '
+ 'here, or move the reference to a scalar comparison operator '
+ '($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be. '
+ 'Ruled 2026-08-11 on #7596: declared = enforced (ADR-0049).'
// The removal ruling of 2026-08-11 lives on the tracker (#7596) — internal
// readers get the id here; the customer-facing sentence keeps the date and
// the customer-resolvable ADR anchor only.
+ 'Ruled 2026-08-11: declared = enforced (ADR-0049).'
);
}

Expand Down Expand Up @@ -1431,7 +1434,9 @@ function normalizedMemberMessage(position: string, input: unknown): string {
+ '({ "field": { "$op": value } }, whose keys are field names and whose operator map '
+ 'must satisfy FieldOperatorsSchema — comparand shapes included), or a nested LOGICAL '
+ `GROUP carrying only ${NORMALIZED_LOGICAL_KEYS.join(' / ')} and nothing else. `
+ 'Ruled on #7711: declared = enforced (ADR-0049).'
// The ruling is #7711 on the tracker — the id stays here for internal
// readers; the sentence keeps the customer-resolvable ADR anchor.
+ 'Declared = enforced (ADR-0049).'
);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ describe('ObjectSchema.create()', () => {
}
expect(message).toContain('lifecycle hook');
expect(message).toContain('record_change');
expect(message).toContain('#1535');
expect(message).toContain('ADR-0032 "no silent failure"');
});

// #4990 note 1 asked whether this file's own `suggestKey` shares the
Expand Down Expand Up @@ -2392,7 +2392,7 @@ describe('#3543 apiMethods legacy-value strip (ObjectCapabilities)', () => {
expect(result.apiMethods).toEqual(['get', 'list']);
const msg = warn.mock.calls.map((c) => c[0]).join('\n');
expect(msg).toContain('export');
expect(msg).toContain('#3543');
expect(msg).toContain('the six primitives get/list/create/update/delete/bulk');
expect(msg).toContain("declare ['list']"); // FROM → TO prescription
});

Expand Down
10 changes: 5 additions & 5 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function stripLegacyApiMethods(
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
warn(
`[spec] enable.apiMethods declares retired legacy value(s) [${legacy.join(', ')}] — ` +
`the ApiMethod enum is the six primitives get/list/create/update/delete/bulk (#3543). ` +
`the ApiMethod enum is the six primitives get/list/create/update/delete/bulk. ` +
`Legacy values are stripped at parse; their semantics are DERIVED from the primitives:\n` +
legacy.map((v) => ` • \`${v}\`: ${LEGACY_API_METHOD_GUIDANCE[v]}`).join('\n') +
(kept.length === 0
Expand Down Expand Up @@ -2361,7 +2361,7 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys:
return new Error(
`ObjectSchema.create('${name}'): unknown key(s) — ${unknownKeys.join(', ')}.\n` +
'These keys would previously have been stripped silently at build, shipping ' +
'dead metadata with no diagnostic (ADR-0032 "no silent failure", issue #1535).\n\n' +
'dead metadata with no diagnostic (ADR-0032 "no silent failure").\n\n' +
`${lines.join('\n')}\n\n` +
'Remove the unknown key(s), fix the typo, or move the logic to a supported mechanism.',
);
Expand Down Expand Up @@ -2502,8 +2502,8 @@ function assertSystemDataIsWritable(
+ '(written via `isSystem` / a service SYSTEM_CTX), or `append-only` for an immutable '
+ 'audit log. If the object IS user-writable, drop the `userActions` entries closing '
+ 'create/edit/delete — the `system-data` default already grants create, edit, delete and '
+ 'exportCsv, so `userActions` is for NARROWING those (#3355). The one verb it does not '
+ 'grant is CSV `import`, which is opt-in per object (#4671).',
+ 'exportCsv, so `userActions` is for NARROWING those. The one verb it does not '
+ 'grant is CSV `import`, which is opt-in per object.',
);
}

Expand Down Expand Up @@ -2604,7 +2604,7 @@ function forceCbpMasterDetailRequired(
throw new Error(
`ObjectSchema.create('${name}'): field \`${fieldName}\` declares \`required: false\` on a `
+ "`master_detail` reference under `sharingModel: 'controlled_by_parent'` — a contradiction "
+ 'with no honest reading (#8772). A controlled-by-parent detail derives ALL of its record '
+ 'with no honest reading. A controlled-by-parent detail derives ALL of its record '
+ 'access from the master this field names (ADR-0055); a row allowed to omit the master FK '
+ 'is unreadable by everyone (the derived filter `masterFK IN (accessible master ids)` '
+ 'never matches null) and unwritable thereafter. Remove `required: false` (the builder '
Expand Down
Loading
Loading