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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/knowledge-source-cron-expression.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec): type `KnowledgeRefreshPolicy.cron` with the shared cron dialect — `CronExpressionInputSchema`, a describe that promises what the parse enforces (#14825)

**BREAKING** accept-set narrowing and parsed-shape change on a published
authorable key, shipped as `minor` under the repo's launch-window convention
for breaking changes.

`KnowledgeRefreshPolicySchema.cron` (`refresh.cron` on a knowledge source) was
a bare `z.string()` under a doc comment promising a 5-field cron expression —
a constraint nothing checked (ADR-0049 declared ≠ enforced). It now carries
`CronExpressionInputSchema`, the cron-dialect input the spec's other
cron-shaped fields already use (`ScheduledExport.schedule.cronExpression`,
`ScheduleState.cronExpression`, `Connector.schedule`).

What changes for authored metadata, measured rather than assumed:

- A bare non-empty string is still the shorthand — `refresh: { cron: '0 3 * * *' }`
keeps parsing; stored `sys_metadata` rows re-parse unchanged.
- The expression envelope `{ dialect: 'cron', source }` is now accepted too.
- An **empty string** is now refused (`invalid_union` at `refresh.cron`); it
named no schedule before, so the only remedy is to delete the key.
- The **parsed** value is now the `{ dialect: 'cron', source }` envelope
rather than the bare string — the same shape the three sibling cron fields
produce. Zero readers of the parsed value were measured in `objectstack` and
the pinned `objectui` (`service-knowledge` reads only `refresh.onRecordChange`;
it never schedules the cron).
- `KnowledgeRefreshPolicyParsed` and `KnowledgeSourceParsed` are new exported
aliases naming the parsed state (ADR-0122); the bare `KnowledgeRefreshPolicy`
/ `KnowledgeSource` aliases stay the author state and still accept a string.

What the schema now promises is exactly what the parse enforces: a non-empty
string or an expression envelope, normalized to the envelope. Cron **syntax**
is not judged at parse time by the shared dialect — `'not a cron'` normalizes
like any other string, and the syntax verdict (5- or 6-field, or an `@yearly`…
`@reboot` alias) is the `cron` dialect engine's when the expression is
evaluated. The describe says so instead of restating "5-field", and the pin
file records the measured behaviour so a later change to the shared dialect
surfaces here.

<!-- adr-0087: not-required (no-migration-prescription) No authorable key is removed, renamed or re-homed and the bare-string shorthand keeps parsing byte-identically, so `objectstack migrate meta` has nothing to rewrite and no tombstone exists; the one newly refused input, an empty string, never named a schedule and has no conversion target; the parsed-side envelope has zero measured readers in objectstack and the pinned objectui. -->
11 changes: 9 additions & 2 deletions content/docs/references/ai/knowledge-source.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string` | optional | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---
Expand All@@ -84,7 +84,7 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **source** | `{ kind: 'object'; object: string; contentFields: string[]; metadataFields?: string[]; … } \| { kind: 'file'; prefix: string; mimeTypes?: string[] } \| { kind: 'http'; urls: string[]; userAgent?: string }` | ✅ | |
| **embedding** | `{ provider: Enum<'openai' \| 'cohere' \| 'azure_openai' \| 'huggingface' \| 'local' \| 'custom'>; model: string; dimensions: integer; endpoint?: string; … }` | optional | |
| **vectorStore** | `{ provider: Enum<'pgvector' \| 'chroma' \| 'qdrant' \| 'pinecone' \| 'weaviate' \| 'milvus' \| 'redis' \| …>; collection: string; endpoint?: string; secretRef?: string; … }` | optional | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string }` | optional (default: `{}`) | |
| **refresh** | `{ onRecordChange?: boolean; cron?: string \| object }` | optional | Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published input-shape JSON Schema cannot state it beside `cron`'s transform). |
| **aiExposed** | `boolean` | optional (default: `true`) | |

### Nested Shape: `KnowledgeSource.source[kind='object']`
Expand DownExpand Up@@ -125,6 +125,13 @@ const result = FileKnowledgeSourceSchema.parse(data);
| **secretRef** | `string` | optional | Reference to stored credential secret |
| **dimensions** | `integer` | optional | |

### Nested Shape: `KnowledgeSource.refresh`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **onRecordChange** | `boolean` | optional (default: `true`) | |
| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. |


---

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit (type)",
"KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource (type)",
"KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema (const)",
"MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema (const)",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"ai/KnowledgeRefreshPolicy:onRecordChange = true",
"ai/KnowledgeSource:adapterConfig = {}",
"ai/KnowledgeSource:aiExposed = true",
"ai/KnowledgeSource:refresh = {}",
"ai/MCPServerRef:active = true",
"ai/MCPToolBinding:approval = \"never\"",
"ai/ModelCapability:codeGeneration = false",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ai.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,10 +64,12 @@
"KnowledgeHit": "src/ai/knowledge-document.zod.ts#KnowledgeHit (type)",
"KnowledgeHitSchema": "src/ai/knowledge-document.zod.ts#KnowledgeHitSchema (const)",
"KnowledgeRefreshPolicy": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicy (type)",
"KnowledgeRefreshPolicyParsed": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicyParsed (type)",
"KnowledgeRefreshPolicySchema": "src/ai/knowledge-source.zod.ts#KnowledgeRefreshPolicySchema (const)",
"KnowledgeSource": "src/ai/knowledge-source.zod.ts#KnowledgeSource (type)",
"KnowledgeSourceKind": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKind (type)",
"KnowledgeSourceKindSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceKindSchema (const)",
"KnowledgeSourceParsed": "src/ai/knowledge-source.zod.ts#KnowledgeSourceParsed (type)",
"KnowledgeSourceSchema": "src/ai/knowledge-source.zod.ts#KnowledgeSourceSchema (const)",
"MCPApprovalPolicy": "src/ai/mcp.zod.ts#MCPApprovalPolicy (type)",
"MCPApprovalPolicySchema": "src/ai/mcp.zod.ts#MCPApprovalPolicySchema (const)",
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,5 +313,22 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
+ 'sequence that never identifies a record), write `unique: false` explicitly; to keep '
+ 'what the schema now does for you, change nothing. Nothing moves for any other type.',
},
{
key: 'ai/KnowledgeSource:refresh',
from: '{}',
to: '(none)',
reason:
'The runtime default did NOT move: a knowledge source that omits `refresh` still parses to '
+ '`refresh: {}` — measured with `KnowledgeSourceSchema.parse` on a minimal source at the '
+ 'base and after the change, byte-identical. What vanished is the `default` keyword in '
+ 'the PUBLISHED JSON Schema. Typing `KnowledgeRefreshPolicy.cron` with the shared cron '
+ 'dialect (`CronExpressionInputSchema`, #14825) puts a transform inside the object that '
+ '`refresh` defaults to, so `ai/KnowledgeSource` now publishes as the input shape, and '
+ "zod's input-mode projection carries no `default` for a `.default()` whose object holds a "
+ 'transform (isolated: the same wrapper around a transform-free object keeps it). Nothing '
+ 'deployed changes behaviour; the consumer affected is one outside this repo who reads '
+ "the published JSON Schema's `default` himself — the `refresh` description now states "
+ 'the materialized default in words, the `data/Field:unique` precedent.',
},
],
};
121 changes: 121 additions & 0 deletions packages/spec/src/ai/knowledge-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `KnowledgeRefreshPolicySchema.cron` — pins for the typed cron slot (#14825).
//
// The slot was a bare `z.string()` under a doc comment promising a 5-field
// cron, so the promise was enforced nowhere (ADR-0049 declared ≠ enforced). It
// now carries `CronExpressionInputSchema`, the shared cron-dialect input the
// other cron-shaped fields already use (`api/export.zod.ts`,
// `automation/execution.zod.ts`, `integration/connector.zod.ts`). What that
// schema ENFORCES was measured before these pins were written, and the pins
// state exactly that — no more:
//
// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`;
// - an expression envelope passes through;
// - an empty string, a non-string, or an envelope naming an unknown dialect
// is refused with `invalid_union` at the slot's own path;
// - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like
// any other string: the syntax verdict belongs to the `cron` dialect engine
// (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when
// the expression is evaluated. That pin is deliberate: it is what keeps the
// schema's describe honest. If the shared dialect ever gains parse-time
// syntax validation, this pin flips, and the describe on the slot must be
// rewritten in the same commit.

import { describe, expect, it } from 'vitest';
import {
KnowledgeRefreshPolicySchema,
KnowledgeSourceSchema,
type KnowledgeRefreshPolicy,
type KnowledgeRefreshPolicyParsed,
type KnowledgeSource,
type KnowledgeSourceParsed,
} from './knowledge-source.zod';

const SOURCE: KnowledgeSource = {
id: 'kb_articles',
label: 'KB articles',
adapter: 'memory',
source: { kind: 'object', object: 'kb_article', contentFields: ['title', 'body'] },
};

const CRON_5_FIELD = '0 3 * * *';

describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', () => {
it('positive control: a 5-field cron on a full knowledge source parses and normalizes to the cron envelope', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
if (!r.success) return;
expect(r.data.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});

it('accepts the expression envelope form and passes it through', () => {
const envelope = { dialect: 'cron' as const, source: '@daily' };
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: envelope });
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data.cron).toEqual(envelope);
});

it('absent stays absent — no `cron` key is fabricated by the parse', () => {
const withEmptyRefresh = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: {} });
expect(withEmptyRefresh.success).toBe(true);
if (withEmptyRefresh.success) expect(withEmptyRefresh.data.refresh?.cron).toBeUndefined();

const withoutRefresh = KnowledgeSourceSchema.safeParse(SOURCE);
expect(withoutRefresh.success).toBe(true);
if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined();
});

it('refuses an empty string with `invalid_union` at `refresh.cron`', () => {
const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } });
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
expect(issue?.code).toBe('invalid_union');
expect(issue?.message.split('.')[0]).toBe('Invalid input');
});

it('refuses a non-string value with `invalid_union` at `cron`', () => {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('refuses an envelope naming a dialect the protocol does not declare', () => {
// `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum).
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]);
});

it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => {
// The syntax verdict is the `cron` dialect engine's at evaluate time:
// `@objectstack/formula` cron-engine accepts 5- or 6-field expressions and
// the `@yearly`…`@reboot` aliases. The parse only normalizes. If this case
// ever goes red because the shared dialect learned to refuse syntax, update
// the slot's describe in the same commit — do not weaken this pin.
for (const source of ['not a cron', '0 0 3 * * *', '@daily']) {
const r = KnowledgeRefreshPolicySchema.safeParse({ cron: source });
expect(r.success, `${JSON.stringify(source)} should normalize, not be refused`).toBe(true);
if (r.success) expect(r.data.cron).toEqual({ dialect: 'cron', source });
}
});

it('names both states (ADR-0122): the bare alias is the author state, `XParsed` the parsed state', () => {
// Author state: a bare string is what an author writes.
const authored: KnowledgeRefreshPolicy = { cron: CRON_5_FIELD };
// Parsed state: the envelope is what a consumer holds after the parse.
const parsed: KnowledgeRefreshPolicyParsed = KnowledgeRefreshPolicySchema.parse(authored);
expect(parsed.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
// @ts-expect-error — a bare string is the AUTHOR shape, not the parsed one.
const notParsed: KnowledgeRefreshPolicyParsed = { cron: CRON_5_FIELD };
expect(notParsed).toBeDefined();

const parsedSource: KnowledgeSourceParsed = KnowledgeSourceSchema.parse({ ...SOURCE, refresh: { cron: CRON_5_FIELD } });
expect(parsedSource.refresh?.cron).toEqual({ dialect: 'cron', source: CRON_5_FIELD });
});
});
38 changes: 34 additions & 4 deletions packages/spec/src/ai/knowledge-source.zod.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { z } from 'zod';
import { CronExpressionInputSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { EmbeddingModelSchema, VectorStoreSchema } from './embedding.zod';

Expand All@@ -25,12 +26,30 @@ export const KnowledgeRefreshPolicySchema = lazySchema(() => z.object({
*/
onRecordChange: z.boolean().default(true).optional(),
/**
* Cron expression (5-field) for periodic full reindex. Optional.
* Cron-dialect expression for a periodic full reindex. Optional.
*
* `CronExpressionInputSchema` — the shared cron-dialect input the other
* cron-shaped fields carry (`api/export.zod.ts`, `automation/execution.zod.ts`,
* `integration/connector.zod.ts`). A bare string is shorthand for
* `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an
* expression envelope and normalizes to the envelope. It does NOT judge cron
* syntax: that verdict is the `cron` dialect engine's (`@objectstack/formula`
* cron-engine — 5- or 6-field, or an `@yearly`…`@reboot` alias) when the
* expression is evaluated, so the describe below promises exactly what the
* parse enforces (ADR-0049 declared = enforced; #14825).
*
* `service-knowledge` does not schedule the cron itself — it merely
* surfaces the value so an automation flow / external scheduler can
* trigger `reindexSource`.
*/
cron: z.string().optional(),
cron: CronExpressionInputSchema.optional().describe(
'Cron-dialect expression for a periodic full reindex. A bare string is shorthand for '
+ '`{ dialect: \'cron\', source }`; the parse enforces a non-empty string or an expression '
+ 'envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is '
+ 'the `cron` dialect engine\'s verdict when the expression is evaluated, not checked here. '
+ '`service-knowledge` does not schedule it: the value is surfaced so an automation flow / '
+ 'external scheduler can trigger `reindexSource`.',
),
}));

/** Source backed by an ObjectQL object — each record becomes a document. */
Expand DownExpand Up@@ -109,15 +128,26 @@ export const KnowledgeSourceSchema = lazySchema(() => z.object({
* adapters own their own backend.
*/
vectorStore: VectorStoreSchema.optional(),
/** Refresh / sync configuration. */
refresh: KnowledgeRefreshPolicySchema.default({}).optional(),
/**
* Refresh / sync configuration. Omitted ⇒ parses to `{}` — the runtime
* default, stated here in words because the published input-shape JSON
* Schema cannot carry it beside `cron`'s transform (#14825).
*/
refresh: KnowledgeRefreshPolicySchema.default({}).optional().describe(
'Refresh / sync configuration; omitted parses to `{}` (the runtime default — the published '
+ "input-shape JSON Schema cannot state it beside `cron`'s transform).",
),
/** Whether `search_knowledge` may expose this source to AI agents. */
aiExposed: z.boolean().default(true).optional(),
}));

export type KnowledgeRefreshPolicy = z.input<typeof KnowledgeRefreshPolicySchema>;
/** Post-parse shape of {@link KnowledgeRefreshPolicy} — defaults applied, transforms run (ADR-0122): `cron` is the `{ dialect: 'cron', source }` envelope. */
export type KnowledgeRefreshPolicyParsed = z.infer<typeof KnowledgeRefreshPolicySchema>;
export type ObjectKnowledgeSource = z.input<typeof ObjectKnowledgeSourceSchema>;
export type FileKnowledgeSource = z.input<typeof FileKnowledgeSourceSchema>;
export type HttpKnowledgeSource = z.input<typeof HttpKnowledgeSourceSchema>;
export type KnowledgeSourceKind = z.input<typeof KnowledgeSourceKindSchema>;
export type KnowledgeSource = z.input<typeof KnowledgeSourceSchema>;
/** Post-parse shape of {@link KnowledgeSource} — defaults applied, transforms run (ADR-0122): `refresh.cron` is the cron envelope. */
export type KnowledgeSourceParsed = z.infer<typeof KnowledgeSourceSchema>;
Loading
Loading