Repository navigation
feat(objectql,platform-objects): keep a by-id delete's rows in the recycle bin (ADR-0148) - #30780
objectstack-fleet[bot] wants to merge 6 commits into
Conversation
…cycle bin
ADR-0148, first slice: the sys_recycle_bin object, the snapshot on
delete, the retention stamp and the TTL reap.
- platform-objects registers sys_recycle_bin, built from the spec's
RECYCLE_BIN_ENTRY_FIELDS: engine-owned, no generic data-API method,
its tenant column cascades, ttl on purge_after.
- objectql: a by-id delete of a business record writes one entry in its
own unit of work, before the root row goes: the cascade set read raw
before the walk (master first, secret columns left out), the set_null
references the walk cleared, deleted_by/deleted_at, retention_days
and purge_after. A removal that fails takes the entry with it.
- retention: ObjectQLPlugin's recycleBinRetention { resolve, token },
else OS_RECYCLE_BIN_RETENTION_DAYS (read at init), else 30 days; a
value that is not a positive whole number is refused.
- spec: sys_recycle_bin joins PLATFORM_OBJECTS_BY_PACKAGE; cli: its
organization-ownership inventory row.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…e was made for deleted_by reads the authorized subject first, then the attributed user of a system-authorized write, the precedence the audit ledger uses. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…ile lives The comment above RECYCLE_BIN_OBJECT described the object file as landing later; it lands with this change, so the comment now names it. The changeset records the skills follow-up that is owed, not moved. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…n shapes the source scans read check:keyed-text-bounds refuses a spread inside `fields` and a `name` that is not a string literal, so sys_recycle_bin now spells each spec entry through its builder (restating the two keyed text bounds) and names itself literally. The plugin test pins every entry equal to the spec's, so the restatement cannot drift. The tenancy census is regenerated for the new in-reach object, and the capture test's driver double applies a caller's bound after its filter and before it copies rows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
📓 Docs Drift CheckThis PR changes 4 package(s): 61 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 179 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a666eedd24dc42521cac4a7f30af913592e9f185 && git checkout a666eedd24dc42521cac4a7f30af913592e9f185
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1371b9d87a798f86fa1217de30dc1072acfec11a 819b02f707dd76ef25254db10092706dd7b8f772 && git checkout -B drift-repro 1371b9d87a798f86fa1217de30dc1072acfec11a && git merge --no-ff 819b02f707dd76ef25254db10092706dd7b8f772
node scripts/docs-audit/affected-docs.mjs --json 1371b9d87a798f86fa1217de30dc1072acfec11a
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Contract reviewServed-tier: Governing text: card #30237 body; unblock 6673225435; claim 6673233448 and its amendment 6673429400 (Q1, Q2, Q4, Q6 seat rulings, PR 1 file surface); the seat's plan Check-runs on this head (API, at review time): 61 runs, 36 The two
① Derived judgments
② Semver level
③ Boundary flags
Author note: PR ④ Design judgment
Should-fix (non-blocking): (a) a pin for Q4 (an organization delete removes its entries and is not refused by them); (b) the Implemented-by: VERDICT: FAIL |
…and the bin's own bookkeeping out of the recycle bin and the ledger
The lifecycle sweep reaps each row with `where: { id }, multi: true`, which
the engine routes as a by-id delete, so a business object that declares a
lifecycle would have entered the bin on every reap. The capture now opens
only when the caller did not ask for `multi: true`. The bin also leaves out
every platform namespace (sys_, cloud_, ai_), not only sys_.
plugin-audit's generic writer would have copied each entry, snapshot and
internal columns included, into sys_audit_log and sys_activity on its reap,
on the set_null of its deleted_by and on an organization delete's cascade.
sys_recycle_bin joins SKIP_OBJECTS beside the other engine-owned plumbing.
Pins: a predicate-shaped by-id delete and a lifecycle sweep of a business
object write no entry; cloud_ and ai_ objects write none; an organization
delete removes its entries without being refused by them; a reap and an
update of an entry write no ledger row while a business delete still does.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Contract reviewServed-tier: Re-record at the new head. The prior record at Check-runs on this head (API, read at 2026-11-06T03:52Z): Each ordered fix, verified against the diff.
The two File surface against the claim as amended. The new commit touches ① Derived judgmentsThe prior record's items 1–10 stand unchanged for the code they judged (roster widening; the object mirrors
② Semver level
③ Boundary flags
Author note: PR ④ Design judgment
Should-fix (non-blocking): (e) the i18n extract config entry, with PR 5 / #30239; (f) the entry-size question, a later card; (g) ③.6 named in PR 2's announcement. Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #30237: the first of five PRs in the seat's split (claim amendment 6673429400). This one adds the object, the snapshot on delete, the retention stamp and the TTL reap. Still on the card: purge, empty and permanent delete (PR 2), restore (PR 3), the permission reader for
allowRestoreOwnDeleted/manageRecycleBin(PR 4) and the REST doors (PR 5).Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin)
维护者速读
sys_recycle_bin,一次删除一条,然后照旧物理删除;生命周期清理器在每条的purge_after到期后删掉它。sys_*、cloud_*、ai_*)不进回收站;回收站条目自身的清理、置空和随组织删除都不写审计账本。回滚就是 revert 本 PR,已写入的条目只是一张没人读的表。packages/objectql/src/recycle-bin/capture.ts(快照与写入、补偿)、packages/objectql/src/engine.ts的runByIdDelete(接入点)、packages/objectql/src/engine-cascade-planning.ts(onCollected与置空引用记录)、packages/plugins/plugin-audit/src/audit-writers.ts的SKIP_OBJECTS。pnpm --filter @objectstack/objectql exec vitest run --project local src/recycle-bin/,预期 2 个文件、47 个测试全过。What changes
@objectstack/platform-objects:sys_recycle_bin(system/sys-recycle-bin.object.ts), registered byPlatformObjectsPluginbesidesys_secret.RECYCLE_BIN_ENTRY_FIELDS, key for key, plus the tenant column. Each entry is spelled through its builder (Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.object_name, maxLength: 255 })) rather than spread in, becausecheck:keyed-text-boundsrefuses a spread and reads a keyed text column's bound only from a literal. The two keyed text bounds are restated, andplugin.test.tspins every entry equal to the spec's, so the table andRecycleBinEntrySchemacannot drift.managedBy: 'engine-owned', hasaccess: { default: 'private' }, and serves no generic data-API method (apiEnabled: false,apiMethods: []): the snapshot holds every field of the deleted rows, so it is read only through the doors of PR 4 and PR 5, under the live object's FLS.organization_iddeclaresdeleteBehavior: 'cascade'(seat ruling Q4): an organization's delete takes its entries with it, and the entries are never counted against that delete.lifecycle: { class: 'transient', ttl: { field: 'purge_after', expireAfter: '0h' } }: the sweep reaps an entry once its stamped deadline passes, and asks no resolver.@objectstack/objectql: the write side, inrecycle-bin/capture.ts(the engine member is a delegation, per the facade gate).delete()'s by-id branch, insiderunByIdDelete, so it shares the ObjectQL.delete's single-id cascade is not transactional — a refusal mid-cascade leaves earlier children deleted while the response says the delete failed #7413 unit of work:openRecycleBinCaptureruns before the cascade, and opens nothing when the caller asked formulti: true. A retention defect refuses the delete here, before any write.cascadeDeleteRelationstakes a newonCollectedcallback. It runs once with the freshly collected set, after the objectql: a lookup withdeleteBehavior: "restrict"is overridden when the same parent is also the child's master_detail target — deleting the parent cascade-deletes the child instead of refusing 409 #26718 restricts are judged and before the walk writes. The capture reads each member's row raw, inside the unit, in BFS order from the root alongcascadeEdges(master first). It keeps declared columns only, leaves outsecretcolumns (D4) and skipssys_*members.set_nullbranch appends{ object, id, field, value }to the new optionalCascadeDeleteSet.clearedReferencesfor both arities (D2's re-link input).driver.delete(root), ONE entry is written raw through the bin's driver with this unit's driver options. If removing the root row throws, the entry goes with it: the transaction's rollback when it covered the bin, else the capture's compensation (logged aterrorif that fails too).deleted_by: the authorized subject, elseattributedUserId(the audit ledger's precedence).organization_id: the root row's tenant column, else the context's.ObjectQLPlugin({ recycleBinRetention: { resolve, token } })→engine.setRecycleBinRetention. The resolver is asked once per delete with the host's token. When it answersundefined:OS_RECYCLE_BIN_RETENTION_DAYS, else 30. A bad answer is refused with the spec'srecycleBinRetentionRefusaltext. The variable is also read once ininit(), so a bad value stops the boot.multi: true), lifecycle reaps included. The engine routes a delete by its id, so the sweep's per-rowwhere: { id }, multi: truereap reaches the by-id branch; the capture is told apart by the caller's own flag;sys_,cloud_orai_namespace (hasPlatformObjectPrefix, the tenancy census's population);infoline per engine. That is reduced functionality, not a lost write.@objectstack/spec(both hunks consented by domain:spec#3 in 6673450505 on mailbox PR #30658):sys_recycle_binjoinsPLATFORM_OBJECTS_BY_PACKAGE['platform-objects'], in the same commit (d374cbe5) as the object file, its registration and the ownership-inventory row. The tenancy census row followed in819b02f7, after its gate reported the drift.//comment aboveRECYCLE_BIN_OBJECTinrecycle-bin.zod.tsnow says in the present tense where the object file lives. No.describe(), TSDoc, shape or export changed, and the module header is untouched.@objectstack/plugin-audit:sys_recycle_binjoinsSKIP_OBJECTS(group 2, besidesys_job_queue/sys_upload_session, with its reason; amendment 6675653517). The generic writer would otherwise copy the whole entry,snapshotand itsinternal: truecolumns included, intosys_audit_logandsys_activityon the sweep's reap, on theset_nullofdeleted_byand on an organization delete's cascade (ADR-0057 §3.3, ADR-0148 D4, the #30751 precedent). The business delete an entry was taken for is audited as before.@objectstack/cli: the organization-ownership inventory row forsys_recycle_bin(fateattribution, likesys_exchange_rate).Census rows:
federated-injected-column-readers.test.tsgains a READERS row for the newresolveTenantFieldName()use (row-value, the realtime publisher's disposition).scripts/platform-object-tenancy-census.jsonis regenerated with--write:sys_recycle_binjoins the population, in reach byorganization_id(registered 93 → 94, in reach 47 → 48).scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json.Tests
packages/objectql/src/recycle-bin/retention.test.ts(26 cases):purge_afterarithmetic.packages/objectql/src/recycle-bin/capture.test.ts(21 cases) runs a realObjectQLon a driver with real rollback:set_nullarities inclearedReferences,deleted_by,organization_id, the schema-valid entry;purge_after=deleted_at+ retention;multi: true, aLifecycleService.sweep()reap of a business object that declares a lifecycle, asys_/cloud_/ai_object, or a composition with no bin (one notice);packages/plugins/plugin-audit/src/audit-writers.test.ts(3 cases): the reap of an entry and the update that clears itsdeleted_bywrite no ledger or activity row, and the business delete still writes both.packages/platform-objects/src/plugin.test.ts: the registration list includesSysRecycleBin, and a new case pins its fields equal to the spec'sRECYCLE_BIN_ENTRY_FIELDS(both sides throughObjectSchema.create) plus the cascading tenant lookup.Acceptance notes
Verification
Head
6a07be61. The contract review of819b02f7(6675639559) failed, and this head fixes both must-fixes, the seat's ruling on question 7 and should-fixes (a) to (d).6a07be61:node scripts/pm/dispatch-gates.mjs --ranreports 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. Every family exited 0, including the twocheck-tenant-audit-censusfamilies that plugin-audit adds, andpnpm lintexited 0. The earlier rounds, on136ad85dand819b02f7, found three reds, fixed in819b02f7: the declaration shape forcheck:keyed-text-bounds, the test double's bound, and the tenancy census.pnpm --filter PKG typecheckexits 0 for@objectstack/objectql,@objectstack/plugin-auditand@objectstack/platform-objects.src/recycle-bin/: 47/47;platform-objectsplugin.test.ts: 23/23.scripts/ablation-replace.mjs. Every suite importssrcrelatively, and every restore printedblob == HEADand an emptygit diff HEAD.openRecycleBinCapture(...)replaced byundefined:capture.test.tsfails 11 of 16 (at819b02f7).object_name's bound restated as 254: the field pin fails.'sys_recycle_bin'removed fromSKIP_OBJECTS: 2 of the 3 new plugin-audit cases fail. The business control stays green.multiflag forced tofalse: themulti: trueby-id case and theLifecycleService.sweep()case fail.recycleBinTakesObjectnarrowed back tosys_only: thecloud_thingandai_thingcases fail.Generated by Claude Code