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 | diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 55fb48d7ab..d65e7d2f5a 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. @@ -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({ @@ -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', }); } }));