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
8 changes: 8 additions & 0 deletions .changeset/lifecycle-prose-family-10526-10336.md
Original file line numberDiff line numberDiff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

Expand Down
24 changes: 18 additions & 6 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -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.
Expand DownExpand Up@@ -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 storageand 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({
Expand DownExpand Up@@ -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,
Expand All@@ -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',
});
}
}));
Expand Down
Loading