From 03dea154976ae3b453c7d9d4d1620962bfc83fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:45:49 +0000 Subject: [PATCH 1/4] docs(spec): re-derive the ttl.onlyWhen x archive refusal's stated reason against the post-#10347 Archiver (#10526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refine's rationale comment and its author-facing message both justified the refusal with a runtime fact that #10347 (PR #10541) retired: "the ttl sweep never runs ... the Archiver moves rows by age alone". Re-derived against the merged Archiver on this base: - `reapObject` still returns into `archiveObject` before the ttl reap branch (lifecycle-service.ts), so the ttl sweep genuinely never runs under `archive` — that half stands. - `archiveObject` now selects candidates by the declared ttl cutoff (`dueField = lc.ttl ? lc.ttl.field : 'created_at'`), so "moves rows by age alone" is false whenever `ttl` is declared — that half is replaced. - Its candidate read is `where: { [dueField]: { $lt: cutoff } }` and nothing else: the WINDOW carries over to the Archiver, the `onlyWhen` FILTER does not. That is the reason today, and the refusal stands on it. Text only: the refusal itself, and every accepted/refused shape, is unchanged (piece 2 of #10526 — whether the refusal should survive at all — is explicitly out of scope and untouched). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --- packages/spec/src/data/object.zod.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 55fb48d7ab..c952117b04 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -977,8 +977,20 @@ export const LifecycleSchema = lazySchema(() => strictObject({ // age with no row read, so rows the filter protects go down with it. // - archive: `reapObject` returns into `archiveObject` before the ttl reap // ever runs, so with `archive` declared the filter guards a code path that - // is never executed (declared ≠ enforced) — while the Archiver itself - // copies and hot-deletes by `created_at` age alone. + // is never executed (declared ≠ enforced). Since [#10347] the Archiver does + // apply the declared ttl window itself — it selects candidates by + // `ttl.field` past `ttl.expireAfter` instead of `created_at` past + // `archive.after` — but its candidate read is that cutoff and nothing else + // (`where: { [ttl.field]: { $lt: cutoff } }`, no `onlyWhen` spread the way + // `reap()` spreads it into its scope), so every due row is copied and + // hot-deleted whether or not the filter names it. That is the whole of what + // [#10347] changed here: the WINDOW an author declares now carries over to + // the Archiver, the FILTER still does not — so the refusal stands, on a + // narrower reason than the "moves rows by age alone" this bullet used to + // give. Whether `onlyWhen` should become meaningful under `archive` (the + // Archiver would have to spread it into the `find` above) is a separate, + // un-taken decision — it widens the accept-set and is not this text's to + // make. if (lc.ttl?.onlyWhen && lc.storage?.strategy === 'rotation') { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -988,7 +1000,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ if (lc.ttl?.onlyWhen && lc.archive) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'lifecycle.ttl.onlyWhen cannot be combined with archive — archive takes over the whole reap (the ttl sweep never runs) and the Archiver moves rows by age alone', + message: 'lifecycle.ttl.onlyWhen cannot be combined with archive — archive takes over the whole reap (the ttl sweep never runs), and while the Archiver does move rows by the declared ttl cutoff it selects them by that cutoff alone and would archive rows the filter protects', }); } })); From b980ec1bbdc85a952b8866c88b1d8265c2499851 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:48:46 +0000 Subject: [PATCH 2/4] docs(spec): state the enforced verb scope in the enable.files / enable.feeds describe strings (#10336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `.describe()` strings said the flag rejects *creation*. Since #10170 (PR #10332) `plugin-audit` registers each capability gate on `beforeUpdate` as well as `beforeInsert` (audit-writers.ts), so both refuse any write that makes a row TARGET the walled object — a create and an update that re-points a `sys_attachment` / re-threads a `sys_comment` alike, 403 FILES_DISABLED / FEEDS_DISABLED. The strings now say that, which is what the docblocks directly above them already said. These strings are not inert prose: `.describe()` reaches the generated schema output and the authoring surfaces built on it, so an author (or an LLM authoring metadata) reading "otherwise creation is rejected" would conclude a re-point is outside the flag's reach. It is not. `enable.activities` is genuinely a mirror-on-write switch and is untouched. Text only: no default, type, or key changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --- packages/spec/src/data/object.zod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index c952117b04..bcbfd62cf1 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -278,7 +278,7 @@ export const ObjectCapabilities = strictObject({ * `Field.file` / `Field.image` column attachments are independent of * this flag. */ - files: z.boolean().default(false).describe('Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent'), + files: z.boolean().default(false).describe('Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent'), /** * Social collaboration (Comments, Mentions, Feeds) — opt-out. @@ -287,7 +287,7 @@ export const ObjectCapabilities = strictObject({ * rejects new `sys_comment` rows targeting this object (403 * FEEDS_DISABLED, enforced at the engine hook seam by plugin-audit). */ - feeds: z.boolean().default(true).describe('Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object'), + feeds: z.boolean().default(true).describe('Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike'), /** * Activity timeline (sys_activity mirror of create/update/delete) — opt-out. From 4309d3549ffe2a65da59459613f8e7dca966519e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:49:12 +0000 Subject: [PATCH 3/4] docs(spec): drop the same retired "archive acts on age alone" claim from ttl.onlyWhen's own describe string (#10526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounded in-place extension of this PR's #10526 member, declared in the PR body. The refine comment and rejection message were not the only places the pre-#10347 rationale was written down: `ttl.onlyWhen`'s `.describe()` closed with "Incompatible with rotation storage and archive, which act on whole shards / age alone" — and by construction that sentence only ever applies when `ttl` IS declared, which is exactly the case where the Archiver now selects by the ttl cutoff rather than by age. Same defect class, same file, same fix already derived for the message; the correct wording is pinned by the merged Archiver read this PR's first commit cites. Leaving it would have shipped a PR whose refine message and whose describe string give contradictory reasons for one refusal. `retention.onlyWhen`'s matching clause is NOT touched and is still accurate: with `retention` and no `ttl` the Archiver moves rows by `created_at` age, and when the triple is declared #10643's refine pins `ttl` to restate `created_at`/`retention.maxAge`, so the ttl cutoff coincides with the age bound in every shape that parses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --- packages/spec/src/data/object.zod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index bcbfd62cf1..d65e7d2f5a 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -876,7 +876,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ field: z.string().describe('Timestamp field the TTL is measured from (e.g. created_at, expires_at).'), expireAfter: lifecycleDuration('ttl.expireAfter').describe('Rows expire this long after `field` and are deleted by the Reaper.'), onlyWhen: lifecycleOnlyWhenSchema.optional().describe( - 'Row filter the TTL reap applies to — per-field equality, {$in: [...]} or the null predicate {$null: true|false} (e.g. { revoked_at: { $null: true } }). Rows OUTSIDE the filter are retained regardless of expiry: for tables that interleave live rows with terminal history a TTL keyed on the same timestamp would otherwise destroy (a sys_session audit tombstone backdates expires_at, so a naive TTL reaps tombstones first). Incompatible with rotation storage and archive, which act on whole shards / age alone.', + 'Row filter the TTL reap applies to — per-field equality, {$in: [...]} or the null predicate {$null: true|false} (e.g. { revoked_at: { $null: true } }). Rows OUTSIDE the filter are retained regardless of expiry: for tables that interleave live rows with terminal history a TTL keyed on the same timestamp would otherwise destroy (a sys_session audit tombstone backdates expires_at, so a naive TTL reaps tombstones first). Incompatible with rotation storage, which DROPs whole shards, and with archive, which selects rows by the ttl cutoff alone and does not apply this filter.', ), }).optional().describe('Per-row TTL auto-expiry (transient/event classes).'), storage: strictObject({ From fcaf1fdc04183db4f3cac5744e4d3c9ff0c23fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 09:54:30 +0000 Subject: [PATCH 4/4] chore(spec): changeset + wholesale reference-projection regen for the prose family (#10526, #10336) `content/docs/references/data/object.mdx` regenerated wholesale via `pnpm --filter @objectstack/spec gen:docs` (never hand-edited). The projection diff is exactly the two `enable` describe strings; the `default:` columns and every other row are untouched, which is the generated-side echo of the clause-(2) probe result. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --- .changeset/lifecycle-prose-family-10526-10336.md | 8 ++++++++ content/docs/references/data/object.mdx | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/lifecycle-prose-family-10526-10336.md diff --git a/.changeset/lifecycle-prose-family-10526-10336.md b/.changeset/lifecycle-prose-family-10526-10336.md new file mode 100644 index 0000000000..7b3934d4c6 --- /dev/null +++ b/.changeset/lifecycle-prose-family-10526-10336.md @@ -0,0 +1,8 @@ +--- +"@objectstack/spec": patch +--- + +Correct two stale author-facing contract statements in `Object.enable` / `Object.lifecycle` — text only, no change to what parses. + +- `lifecycle.ttl.onlyWhen` × `archive` (#10526): the refusal's rejection message no longer says "the Archiver moves rows by age alone". Since #10347 the Archiver selects candidates by the declared ttl cutoff, so that reason had gone stale; the reason it states now is the one that holds — the ttl **window** carries over to the Archiver, the `onlyWhen` **filter** does not, so the filtered-out rows would still be archived. The refusal itself is unchanged. +- `enable.files` / `enable.feeds` (#10336): the two `.describe()` strings said the flags reject *creation*. Since #10170 both capability gates are registered on `beforeUpdate` as well, so they refuse any write that makes a row **target** the walled object — a create and an update that re-points/re-threads an existing row alike (403 `FILES_DISABLED` / `FEEDS_DISABLED`). The strings now state that, matching the docblocks above them. `enable.activities` is unaffected and untouched. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9f7d0bc198..32f001de13 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -176,8 +176,8 @@ const result = ApiMethod.parse(data); | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | -| **files** | `boolean` | optional (default: `false`) | Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent | -| **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object | +| **files** | `boolean` | optional (default: `false`) | Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent | +| **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike | | **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline | | **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |