Skip to content

Commit 60644d7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21409-star-count-only
# Conflicts: # packages/spec/dropped-refinements.baseline.json
2 parents 934b70a + 39a912e commit 60644d7

24 files changed

Lines changed: 1687 additions & 142 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec)!: an agent's `memory` contract states exactly what the runtime honours — `maxEntries` and `reflectionInterval` are required once long-term memory is enabled, `longTerm.store` is retired, and the block is `live`, enforced by the cloud AI runtime (#20274)
7+
8+
**BREAKING** — `agent.memory` narrows to what the cloud AI runtime, the one runtime
9+
that executes agents, actually does with it. That runtime recalls the newest
10+
`maxEntries` distilled notes for the user before the first round, writes one note
11+
every `reflectionInterval` delivered interactions, evicts notes beyond `maxEntries`,
12+
and keeps them in its own database store. Before an agent's first turn it refused
13+
exactly the declarations this spec still accepted, so authoring now refuses them,
14+
by name, with a prescription (ADR-0049 enforce-or-remove):
15+
16+
- **`longTerm.maxEntries` and `reflectionInterval` are required when
17+
`longTerm.enabled` is true.** No default is declared for either: none has a
18+
measured basis, and the runtime adds none.
19+
- **`reflectionInterval` is refused without an enabled `longTerm`** — a reflection
20+
writes a long-term note, so with none enabled it would do nothing.
21+
- **`longTerm.store` is retired as a whole key.** The memory store is platform
22+
infrastructure, not agent metadata: the runtime keeps the notes in its own
23+
database store, and refused `vector` (the key's default, so what an omitted
24+
`store` parsed to) and `redis`. Its old spellings `backend`, `storage` and
25+
`provider` under `longTerm` are answered with the same prescription instead of
26+
being steered onto `store`.
27+
28+
`longTerm.enabled` is unchanged.
29+
30+
### FROM → TO
31+
32+
| before | what to write instead |
33+
| --- | --- |
34+
| `memory.longTerm.store` — any value, `database` included | delete the key; where the notes are kept is the platform's choice. |
35+
| `longTerm: { enabled: true, … }` without `maxEntries` | add `maxEntries`: how many distilled notes are kept for each user (an integer of at least 1). |
36+
| `longTerm: { enabled: true, … }` without `memory.reflectionInterval` | add `reflectionInterval`: how many delivered interactions pass between the reflections that write a note (an integer of at least 1). |
37+
| `memory.reflectionInterval` without `longTerm.enabled: true` | enable long-term memory with both numbers, or delete `reflectionInterval`. |
38+
39+
**The one-line fix: declare `maxEntries` and `reflectionInterval` when `longTerm.enabled`; delete `store`.**
40+
`os migrate meta --from 17` lists the mechanical edits for existing sources (the
41+
`store` deletion); the two numbers are the author's to choose.
42+
43+
Each refusal is a parse error at the key's own path, naming the key and the fix, and
44+
`store` also fails `tsc` (its input type is `never`).
45+
46+
### The retirement kit
47+
48+
- **Tombstone.** `longTerm.store` is a `retiredKey()` carrying the prescription; the
49+
three old alias spellings moved from `aliases` to `guidance`, because an alias may
50+
not steer an author onto a tombstone.
51+
- **The contract check** is a refinement on `memory` (`reflectionInterval` is
52+
`longTerm`'s sibling), one `custom` issue per missing or misplaced key. A JSON
53+
Schema cannot state a value-conditioned requirement in the closed projection list,
54+
so the published `ai/Agent` schema (and the four installed-package schemas that
55+
embed agents) names the site in `x-dropped-refinements`, recorded in
56+
`dropped-refinements.baseline.json`.
57+
- **D2 conversion `agent-memory-long-term-store-removed`** (step 18, retired from the
58+
load path): it deletes `store` from `memory.longTerm`, whatever it holds — the
59+
delete is lossless, because no value of it ever chose a backend. Stored
60+
`sys_metadata` agent rows and built artifacts replay it; one notice per agent. It
61+
supplies neither number.
62+
- **D3 entry `agent-memory-store-retired-and-limits-required`** carries the judgement
63+
the conversion cannot make: the two numbers an enabled `longTerm` now requires.
64+
- **`RETIRED_KEYS_BY_MAJOR[18]`** registers `ai/Agent:memory.longTerm.store`.
65+
- **No deprecation window**, per the project's startup-stage posture.
66+
67+
### Describes and the liveness ledger
68+
69+
- `agent.memory` drops `[EXPERIMENTAL — not enforced]`: it states that the cloud AI
70+
runtime enforces it and that the open framework edition does not run agents.
71+
`longTerm`, `enabled`, `maxEntries` and `reflectionInterval` each state what the
72+
runtime does with them.
73+
- The ledger row moves `experimental` → `live`, citing the cloud reader
74+
`agent-runtime.ts#compileAgentMemory` (via `AgentRuntime.resolveTurnGuardrails`),
75+
the enforcement in `ai-service.ts` and the store `agent-memory.ts#AgentMemoryStore`,
76+
as attested by the cloud seat's reading at cloud `ef5a4344`, `verifiedAt`
77+
2026-10-02. `os lint` / `os validate` no longer warn
78+
`liveness-experimental-property` on an agent that sets `memory`.
79+
- ⚠️ **The window, stated.** At `ef5a4344` the cloud reader still reads `store`: it
80+
honours `database` only and refuses `vector` and `redis`. Cloud drops `store` in
81+
that one reader once this release reaches its pin, and no earlier.
82+
83+
### The agent form's help texts
84+
85+
- The `memory` row's help text on the agent metadata form named short-term memory,
86+
a key the schema refuses. It now states what memory does and that `maxEntries`
87+
and `reflectionInterval` are required once long-term memory is enabled.
88+
- The neighbouring `planning` row named a strategy and a replan switch the schema
89+
does not declare; it now states the one key it has, the iteration cap.
90+
- The `platform-objects` metadata-form catalogs follow: the English leaves are
91+
regenerated, and the `zh-CN`, `ja-JP` and `es-ES` leaves are authored, not copied.
92+
93+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is
94+
published, and tenant-authored agents were not measured. This repo authors no
95+
`longTerm` outside `packages/spec`, and no cloud built-in agent declares one.
96+
97+
Clause-②: yes (narrowing)
98+
99+
<!-- adr-0087: registered agent-memory-long-term-store-removed, agent-memory-store-retired-and-limits-required -->
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: `os validate`, `os build` and `os lint` refuse an `analyticsCubes` dimension over a JSON-stored column, and a cube `count_distinct` measure over one, which the analytics door already refuses at query time
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at authoring of two cube member TARGETS the analytics door already refuses with 400 INVALID_FIELD at query time: an analyticsCubes dimension whose sql column is declared structured-JSON (json, composite, repeater, record, location, address, vector) or multi-value (multiselect, checkboxes, tags, or a select, radio, lookup, user, file or image declared multiple: true), and a count_distinct measure whose sql column is either. It is the cube face of the dataset dimension refusal, which declared this category for the same door. No authorable key, spelling, export or stored shape moves: CubeSchema keeps parsing every member, no stored row is read or rewritten, and which scalar part of a document, or which member of a list, an author meant to group on or count is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); dataset-measure-aggregate-field-type-refused scopes itself to DatasetMeasureSchema rows and no ADR-0087 id covers a cube member (not already-registered); and the change is a rule verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING**: metadata that passed `os validate`, `os build` and `os lint` can now fail. An authored analytics cube (`defineStack({ analyticsCubes })`) is queried through the same analytics door as a compiled dataset, and that door refuses a query that groups by a JSON-stored column, or counts its distinct values, with `400 INVALID_FIELD` before any SQL is built. So such a member could be declared but never served, and until now no authoring rule read `analyticsCubes` at all. The dataset rule's two ids now judge cube members as well: `dimension-json-stored-field-refused` and `measure-aggregate-field-type-refused` (gating, `error`). It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed.
12+
13+
**What is refused.** On a cube whose `sql` names an object the stack defines: a `dimensions` entry whose `sql` column is declared with a structured-JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) or a multi-value declaration (`multiselect`, `checkboxes`, `tags`, or a `select`, `radio`, `lookup`, `user`, `file` or `image` declared `multiple: true`); and a `measures` entry of `type: 'count_distinct'` whose `sql` column is either. The column is the member's `sql`: a column of the cube's object, or a relationship path read on the object its last hop reaches (the join the cube declares for that hop, else the lookup field's `reference`). The classes are `@objectstack/spec/data`'s `STRUCTURED_JSON_TYPES`, `isMultiValueField` and the `count_distinct` row of `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, the predicates the door reads.
14+
15+
**What an author sees now.** The finding names the cube, the member, the column, the object that declares it and its declaration, and says the analytics door refuses it with `400 INVALID_FIELD`. It names the route: group by, or count the distinct values of, a field that stores one scalar value; for a multi-value field, filter by one member with `$contains` in a record query. It is located at `analyticsCubes[N].dimensions.KEY.sql` or `analyticsCubes[N].measures.KEY.type`, where `KEY` is the member's key.
16+
17+
**Unchanged.** Every dataset finding, word for word. A cube member over any other column, a single-value `select` or `lookup` included; a `count` measure, and a `sum`, `avg`, `min` or `max` measure, which this check does not judge; the row wildcard `'*'`; a member whose column does not resolve or declares no type; a cube whose `sql` names no object this stack defines. The runtime metadata write door: no authoring rule is dispatched for an `analytics_cube` save, and a `dataset` save's snapshot carries no cubes.

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ above declared `multiple: true`), is refused
232232
Group by a field that stores one value instead. Like the measure check, it
233233
stays silent when the field's type cannot be resolved.
234234

235+
Both checks also judge the members of an analytics cube (`analyticsCubes`),
236+
because an authored cube is queried through the same analytics door. A cube
237+
dimension whose `sql` column is declared with a structured-JSON type or as a
238+
multi-value field is refused (`dimension-json-stored-field-refused`) at
239+
`analyticsCubes[N].dimensions.<key>.sql`, and so is a `count_distinct` measure
240+
over such a column (`measure-aggregate-field-type-refused`, at
241+
`analyticsCubes[N].measures.<key>.type`). The column is read where the door
242+
reads it: on the object the cube's `sql` names, or, for a relationship path,
243+
on the object the last hop reaches — the join the cube declares for that hop,
244+
else the lookup's `reference`. A cube's other measure types and the row
245+
wildcard `'*'` are not judged by this check.
246+
235247
### 7. Navigation exposing objects nobody can read
236248

237249
Navigation and permissions are separate metadata, each valid on its own — so an

‎content/docs/references/ai/agent.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const result = AIModelConfigSchema.parse(data);
5858
| **access** | `string[]` | optional | Who can chat with this agent |
5959
| **permissions** | `string[]` | optional | Required permission-set capabilities |
6060
| **planning** | `{ maxIterations: integer }` | optional | Autonomous reasoning and planning configuration |
61-
| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | [EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet. |
61+
| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | Agent memory (long-term notes recalled before each conversation and written by periodic reflection), enforced by the cloud AI runtime; the open framework edition does not run agents. |
6262
| **guardrails** | `{ maxTokensPerInvocation?: integer; maxExecutionTimeSec?: integer; blockedTopics?: string[] }` | optional | Safety guardrails for the agent (token budget, time limit, blocked topics), enforced per user turn by the cloud AI runtime; the open framework edition does not run agents. |
6363
| **structuredOutput** | `{ format: Enum<'json_object' \| 'json_schema'>; schema?: Record<string, any>; strict: boolean; retryOnValidationFailure: boolean; … }` | optional | Structured output contract for the agent's final answer (JSON format, schema, retries, fallback format, transform steps), enforced on every final answer by the cloud AI runtime; the open framework edition does not run agents. |
6464
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this agent. |
@@ -101,8 +101,8 @@ const result = AIModelConfigSchema.parse(data);
101101

102102
| Property | Type | Required | Description |
103103
| :--- | :--- | :--- | :--- |
104-
| **longTerm** | `{ enabled: boolean; store: Enum<'vector' \| 'database' \| 'redis'>; maxEntries?: integer }` | optional | Long-term / persistent memory |
105-
| **reflectionInterval** | `integer` | optional | Reflect every N interactions to improve behavior |
104+
| **longTerm** | `{ enabled: boolean; maxEntries?: integer }` | optional | Long-term memory: distilled notes kept per user and agent and recalled before each conversation |
105+
| **reflectionInterval** | `integer` | optional | Reflect every N delivered interactions: each reflection writes one distilled note to long-term memory. Required when longTerm.enabled is true, and refused without it |
106106

107107
### Nested Shape: `Agent.guardrails`
108108

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
718718
// over the shipped dataset corpus before crossing, at the door's own
719719
// snapshot shape: 11 datasets (platform-objects 5, showcase 4, crm 1,
720720
// todo 1) — 0 findings, with a lit synthetic probe refused.
721+
//
722+
// [#21082] The rule also walks `analyticsCubes` (the cube leg, same two
723+
// ids). That leg is CLI-only by construction, not by a narrowing here: a
724+
// `dataset` write's snapshot carries no `analyticsCubes` (the context
725+
// collections are `RuntimeStackContext`'s), so it reads nothing at this
726+
// door, and an `analytics_cube` write has no `TYPE_TO_STACK_KEY` row.
721727
surfaces: CLI_AND_RUNTIME,
722728
runtimeTypes: ['dataset'],
723729
run: (stack) => validateDatasetMeasureAggregates(stack),

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,13 @@ describe('lintLivenessProperties', () => {
119119
expect(ruleOf(findings, 'nodes.outputSchema')).toBe(LIVENESS_DEAD_PROPERTY);
120120
});
121121

122-
it('warns on an experimental prop with no authorWarn of its own (agent.memory)', () => {
122+
it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => {
123123
// `experimental` warns implicitly — shouldWarn() treats a declared-but-
124124
// unenforced guarantee like an opted-in dead prop. Repointed from
125-
// action.undoable in #3714, which turned out to have two objectui readers.
126-
const findings = lintLivenessProperties({ agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] });
127-
const f = findings.find((x) => x.message.includes('`memory`'));
125+
// action.undoable in #3714, which turned out to have two objectui readers,
126+
// and from agent.memory in #20274, which the cloud AI runtime enforces.
127+
const findings = lintLivenessProperties({ tools: [{ name: 't1', outputSchema: { type: 'object' } }] });
128+
const f = findings.find((x) => x.message.includes('`outputSchema`'));
128129
expect(f).toBeDefined();
129130
expect(f!.rule).toBe('liveness-experimental-property');
130131
});
@@ -930,12 +931,12 @@ describe('lintLivenessProperties', () => {
930931
describe('never throws on a malformed collection item (#11385)', () => {
931932
it('flat TYPE_COLLECTIONS loop: skips a null item and keeps walking past it', () => {
932933
const findings = lintLivenessProperties({
933-
// agent.memory is a real, currently-`experimental` ledger row (see
934+
// tool.outputSchema is a real, currently-`experimental` ledger row (see
934935
// "warns on an experimental prop" above) — a real ledger witness,
935936
// not a synthetic one.
936-
agents: [null, { name: 'ag1', memory: { kind: 'buffer' } }],
937+
tools: [null, { name: 't1', outputSchema: { type: 'object' } }],
937938
});
938-
expect(paths(findings).some((m) => m.includes('`memory`'))).toBe(true);
939+
expect(paths(findings).some((m) => m.includes('`outputSchema`'))).toBe(true);
939940
});
940941

941942
it('object walk: skips a null item and keeps walking past it', () => {
@@ -1596,12 +1597,12 @@ describe('a per-type ledger that could not be READ is reported once (#19276)', (
15961597
});
15971598

15981599
it('keeps walking the types whose ledgers ARE readable, and puts the fault first', () => {
1599-
// agent.memory is a real `experimental` row, so this proves the fault does
1600-
// not abort the pass: one type is dark, the rest still enforce, and the
1601-
// line that explains the darkness is the one a reader meets first.
1600+
// tool.outputSchema is a real `experimental` row, so this proves the fault
1601+
// does not abort the pass: one type is dark, the rest still enforce, and
1602+
// the line that explains the darkness is the one a reader meets first.
16021603
const findings = lintLivenessPropertiesFromLedgerDir(
16031604
ledgerDirWith((dir) => rmSync(join(dir, 'object.json'))),
1604-
{ agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] },
1605+
{ tools: [{ name: 't1', outputSchema: { type: 'object' } }] },
16051606
);
16061607
expect(findings.map((f) => f.rule)).toEqual([LIVENESS_LEDGER_UNREADABLE, LIVENESS_EXPERIMENTAL_PROPERTY]);
16071608
expect(findings[0].where).toBe("liveness ledger 'object'");

0 commit comments

Comments
 (0)