Skip to content

Commit 52db1d1

Browse files
Elon Muskclaude
andauthored
docs(spec): re-derive two stale contract statements in object.zod.ts against the behaviour that landed (#10526, #10336) (#10733)
* docs(spec): re-derive the ttl.onlyWhen x archive refusal's stated reason against the post-#10347 Archiver (#10526) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw * docs(spec): state the enforced verb scope in the enable.files / enable.feeds describe strings (#10336) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw * docs(spec): drop the same retired "archive acts on age alone" claim from ttl.onlyWhen's own describe string (#10526) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7ea328 commit 52db1d1

3 files changed

Lines changed: 28 additions & 8 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Correct two stale author-facing contract statements in `Object.enable` / `Object.lifecycle` — text only, no change to what parses.
6+
7+
- `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.
8+
- `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.

content/docs/references/data/object.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ const result = ApiMethod.parse(data);
176176
| **searchable** | `boolean` | optional (default: `true`) | Index records for global search |
177177
| **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs |
178178
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
179-
| **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 |
180-
| **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object |
179+
| **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 |
180+
| **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 |
181181
| **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
182182
| **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |
183183

packages/spec/src/data/object.zod.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export const ObjectCapabilities = strictObject({
278278
* `Field.file` / `Field.image` column attachments are independent of
279279
* this flag.
280280
*/
281-
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'),
281+
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'),
282282

283283
/**
284284
* Social collaboration (Comments, Mentions, Feeds) — opt-out.
@@ -287,7 +287,7 @@ export const ObjectCapabilities = strictObject({
287287
* rejects new `sys_comment` rows targeting this object (403
288288
* FEEDS_DISABLED, enforced at the engine hook seam by plugin-audit).
289289
*/
290-
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'),
290+
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'),
291291

292292
/**
293293
* Activity timeline (sys_activity mirror of create/update/delete) — opt-out.
@@ -876,7 +876,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({
876876
field: z.string().describe('Timestamp field the TTL is measured from (e.g. created_at, expires_at).'),
877877
expireAfter: lifecycleDuration('ttl.expireAfter').describe('Rows expire this long after `field` and are deleted by the Reaper.'),
878878
onlyWhen: lifecycleOnlyWhenSchema.optional().describe(
879-
'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.',
879+
'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.',
880880
),
881881
}).optional().describe('Per-row TTL auto-expiry (transient/event classes).'),
882882
storage: strictObject({
@@ -977,8 +977,20 @@ export const LifecycleSchema = lazySchema(() => strictObject({
977977
// age with no row read, so rows the filter protects go down with it.
978978
// - archive: `reapObject` returns into `archiveObject` before the ttl reap
979979
// ever runs, so with `archive` declared the filter guards a code path that
980-
// is never executed (declared ≠ enforced) — while the Archiver itself
981-
// copies and hot-deletes by `created_at` age alone.
980+
// is never executed (declared ≠ enforced). Since [#10347] the Archiver does
981+
// apply the declared ttl window itself — it selects candidates by
982+
// `ttl.field` past `ttl.expireAfter` instead of `created_at` past
983+
// `archive.after` — but its candidate read is that cutoff and nothing else
984+
// (`where: { [ttl.field]: { $lt: cutoff } }`, no `onlyWhen` spread the way
985+
// `reap()` spreads it into its scope), so every due row is copied and
986+
// hot-deleted whether or not the filter names it. That is the whole of what
987+
// [#10347] changed here: the WINDOW an author declares now carries over to
988+
// the Archiver, the FILTER still does not — so the refusal stands, on a
989+
// narrower reason than the "moves rows by age alone" this bullet used to
990+
// give. Whether `onlyWhen` should become meaningful under `archive` (the
991+
// Archiver would have to spread it into the `find` above) is a separate,
992+
// un-taken decision — it widens the accept-set and is not this text's to
993+
// make.
982994
if (lc.ttl?.onlyWhen && lc.storage?.strategy === 'rotation') {
983995
ctx.addIssue({
984996
code: z.ZodIssueCode.custom,
@@ -988,7 +1000,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({
9881000
if (lc.ttl?.onlyWhen && lc.archive) {
9891001
ctx.addIssue({
9901002
code: z.ZodIssueCode.custom,
991-
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',
1003+
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',
9921004
});
9931005
}
9941006
}));

0 commit comments

Comments
 (0)