Skip to content

feat(cli): os db orphans — report detail rows whose required master-detail master is missing, and delete them only behind --delete after a pre-delete export - #25039

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24964-orphan-detail-operator-command
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24964-orphan-detail-operator-command

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24964

Clause-②: yes (widening) (the new public method ObjectQL.inspectCascadeDelete on @objectstack/objectql, and the new command os db orphans)

The line above is the seat's re-declaration (the claim, 6305964995, said no): this PR adds one public method, ObjectQL.inspectCascadeDelete, to the exported ObjectQL class. It reaches the published declaration closure: packages/objectql/dist/util-*.d.ts, which dist/index.d.ts re-exports the class from, carries 2 hits (declaration and doc); the control, inspectDanglingReferences, carries 3 hits in the same file. So @objectstack/objectql's published surface widens by one method, both packages are graded minor, and the changeset carries the same Clause-②: yes (widening) line.

What this does

os db orphans is the operator's door to the detail rows whose required master_detail master does not exist. A system-context write is exempt from the write-time reference check, so an elevated writer can store a master id that names no master. Every user door then answers 404 for that row, and the integrity audit reports it and never rewrites it.

  • Default run: a report that writes no row, no table and no file. It boots read-only, as os migrate plan does. It lists each orphan's object, record id, field and missing master id, and the deeper detail rows its delete removes with it.
  • --delete requires --export PATH. Every record the run deletes is written as stored, owner-only (0600, created with wx), and read back before the first delete. Each delete is the engine's own system-context delete(), so the master-detail's cascade removes the deeper details.
  • Never deleted: an undetermined reference (its target object could not be checked), a dangling reference on a field that is not a required master-detail (named in otherDangling), and an orphan whose cascade could not be read (withheld).
  • @objectstack/objectql: inspectCascadeDelete(object, id). A read-only method that returns what a by-id delete() removes, root first. It returns the cascade set the delete collects for itself (collectCascadeDeleteSet), read through the same walk. It files no reference-check elevation record, because no delete happens.

The ruling's five constraints (6303808312), and where each is held

# Constraint Held by
1 Explicit operator command; nothing on any boot, upgrade, migrate or lifecycle path invokes it orphans.no-auto-run.test.ts. A resolving import scan: no source file imports the command, no source file but the command imports its plan, and no source file but those two calls .inspectCascadeDelete(. Positive controls run first, including serve.ts's dynamic import of the boot migration gate. The one-shot family pin (schema-migrate.one-shot-family.integration.test.ts) also shows its boots arm no lifecycle sweep.
2 Default run is a dry-run report: object, record id, field, missing master id orphans.integration.test.ts, the "lists the orphan…" case. The database is byte-identical before and after (schema plus every row), and no export file exists.
3 Delete only behind an explicit flag, only rows the same run listed --delete refuses without --export (export_required). Just before deleting, the audit runs again, and an orphan no longer dangling as reported is skipped. Before its own delete, each orphan's cascade is read again, and one that now reaches a record the export lacks is skipped.
4 Mandatory pre-delete export, written before the first delete The order pin intercepts the command's first ObjectQL.prototype.delete call and reads the export there. It finds ['od_member/m_orphan', 'od_member_note/n_orphan'] already on disk. Refusals cover an unwritable path (export_failed) and an existing path (export_exists); in both, nothing is deleted.
5 Deletion applies the master-detail's own delete semantics; deeper details listed in the dry run The delete is engine.delete(object, { where: { id }, context: { isSystem: true } }). Deeper details come from inspectCascadeDelete. In the objectql test, its listing equals what delete() then removes. Declared, as the ruling asks: the dry run lists every record a cascade relation reaches, at any depth. A restrict dependent outside the cascade is not listed, because it removes nothing. It refuses the delete, and the cascade rolls back as one unit (pinned: the note the cascade reached first is back). A set_null reference to a deleted record is not listed either, because it removes nothing; the engine clears it. That clearing is the engine's existing behaviour and NOT MEASURED by this PR.

Mechanism hypotheses, measured

  • H1 holds. inspectDanglingReferences({ objects }) returned, on a measurement boot (real kernel, SQL driver; fixture names oc_*), {"objectName":"oc_member","recordId":"…","field":"campaign","target":"oc_campaign","value":"Spring Promo"} in dangling. One limit: undetermined is a count, not itemised. So the report counts unprobeable references and names none of them. The pin uses a master object that is not registered at all: the row is counted, never listed, and never deleted.
  • H2 holds, with one measured wrinkle. A system-context delete() of the orphan removed it and its required master-detail child (the cascade). When the missing master has a roll-up over the detail, the delete throws ERR_SUMMARY_RECOMPUTE (SummaryRecomputeError, written: true) after the rows are already removed: "Record Spring Promo not found in oc_campaign". The command recognises that code through SUMMARY_RECOMPUTE_CODE. It judges every delete by reading the cascade's records back, never by what the call returned. It reports a note naming the roll-up on the missing master. Pinned in the main delete case.
  • H3 holds. The shape mirrors os secret orphans: bootSchemaStack (read-only for the report, plain for --delete), --delete plus --export, wx and 0600, a read-back check, --yes, and --json as one document. Reused as is: absentTableReads, describeDriverSqliteFile with JOURNAL_MODE_SWITCH_NOTE, and the format.ts helpers. PlatformObjectsPlugin is composed as os serve composes it, so the cascade sees the platform's relations.
  • The seat's question: yes, one new method. Measured on the built closure (above). The alternative was a copy of "which relations cascade" in the CLI, which would be free to disagree with the delete it previews.
  • Premise re-check: git grep -n "danglingReferences\|inspectDanglingReferences" origin/main -- packages/cli/src packages/rest/src packages/runtime/src hits only schema-migrate.teardown.integration.test.ts, a lifecycle-sweep test, so no source door existed. The control, the same grep in packages/objectql/src, hits engine.ts (3) and lifecycle-service.ts (3). The HotCRM measurement (an administrator's GET/DELETE answer 404) is NOT MEASURED here: this boot composes no security plugin, and a user-context read in it returned the row.

Ablation (one leg each, through scripts/ablation-replace.mjs; both restores proven blob == HEAD, git diff HEAD empty)

  • Master-detail filter (isRequiredMasterFinding returns true): 5 of 9 integration cases red and 1 of 6 unit cases red. A dangling lookup on the live member made it an "orphan", and the live member was deleted. The source is imported directly from src/, so no rebuild is involved.
  • Export before the first delete (verified from memory, not written): 3 of 9 integration cases red. The order pin read "no file" at the first delete, the main delete case found no export, and the unwritable-path refusal no longer refused.

Tests and gates

All at head 0770e4fc9 unless a sha is named. Exit codes were captured before any pipe.

  • @objectstack/objectql, targeted (local project): vitest run --project local --maxWorkers=2 engine-cascade engine-inspect-cascade-delete engine-dangling-reference integrity/dangling-reference lifecycle-service passed 18 files and 328 tests (exit 0). The new engine-inspect-cascade-delete.test.ts has 5 cases. Declared narrowing: the package's other test files were not run locally, and CI's Test Core runs them all. The narrowing is by file name: tests that reach the cascade walk under another name are not in it.
  • @objectstack/cli, unit tier, whole: vitest run --project unit --maxWorkers=2 passed 318 files and 4699 tests (exit 0).
  • @objectstack/cli, integration tier, targeted at the files this diff touches: orphans.integration.test.ts passed 9/9 (exit 0). In schema-migrate.one-shot-family.integration.test.ts at 26706a69a, -t selected the db orphans rows, the table-equals-source case and the lifecycle-arming cases: 10 passed, 98 skipped (exit 0). Declared to CI: the rest of the integration tier, and test/json-stdout-purity.e2e.test.ts (its single-member spawn is in the acceptance notes).
  • Typecheck: pnpm --filter @objectstack/cli typecheck and pnpm --filter @objectstack/objectql typecheck both exit 0, check:test-typecheck included, with no new debt.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 105 commands, and every one exits 0. --ran reconciles: "105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0).
    • The first run at 43f215942 had one real red. check:slot-lookup flagged an any lookup in the new integration test, now typed (0770e4fc9).
    • Four refused with a prerequisite, not a finding. check-engine-split-ratio refused on shallow history, and history was deepened. check:skill-examples, check:dual-build-cjs-loads and check:i18n-coverage refused for missing builds of packages outside this diff. All four are green on the re-run at this head.
  • Lint, the repo-wide scan, is CI's.

Acceptance notes

  • The undetermined bucket is a count in the audit's report (DanglingReferenceReport.undetermined), so the command can say how many references it could not check, but not which rows hold them. Itemising it would change the audit's published report shape. Noted, not filed. Carrier: none.
  • "Required master-detail" is read through isControlledByParentMasterField (@objectstack/spec/data). Its body is exactly type === 'master_detail' && required === true, and it is the one place the spec spells that. The name speaks of controlled_by_parent; the predicate does not depend on the sharing model. Noted, not filed. Carrier: none.
  • The json-stdout-purity e2e (a spawn-tier file that runs every family member in sequence) gains its db orphans row and is declared to CI. Locally, the same spawn was driven for this member alone (tsx bin/run-dev.js db orphans --json against a project with no database). It exited 0 with one JSON document on stdout, the boot diagnostics on stderr, and no database file created.

Generated by Claude Code

…s, read-only

The operator's remover of orphaned master-detail rows must list, in its dry
run, the deeper details its delete reaches. The engine answers that with the
cascade set delete() already collects, through the same walk, filing no
elevation record (none is a delete).

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/objectql, touching 55 documentable anchor(s).

34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 18 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9da6534f7fbe527c3ded637babcf88f5765863ae — the merge of head 5231f8fd739fa365fb184c20b5450ee4c7a82966 into base d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9da6534f7fbe527c3ded637babcf88f5765863ae && git checkout 9da6534f7fbe527c3ded637babcf88f5765863ae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f 5231f8fd739fa365fb184c20b5450ee4c7a82966 && git checkout -B drift-repro d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f && git merge --no-ff 5231f8fd739fa365fb184c20b5450ee4c7a82966

node scripts/docs-audit/affected-docs.mjs --json d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… changeset

The PR adds one public method, ObjectQL.inspectCascadeDelete, measured in the
published declaration closure of @objectstack/objectql, plus the new os db
orphans command. The declaration line lands in the changeset beside the PR
body's, as other changesets on main place it. Levels stay minor for both
packages.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 5231f8fd739fa365fb184c20b5450ee4c7a82966
Local-runs: none

The face that makes this review owed is the Clause-②: yes (widening) declaration alone, re-declared by the seat after the dev measured a new public method. This is the lane seat's review of record. No published packages/spec schema and no governed path is in the diff (check-governed-merges.mjs --pr 25039: NOT governed).

① Derived judgments

  • ObjectQL.inspectCascadeDelete(object, id), a new public method on the exported ObjectQL class. RIGHT as declared. The dev measured it in @objectstack/objectql's published dist/util-*.d.ts (2 hits; control inspectDanglingReferences 3), so the published surface widens by one method. It is read-only: it calls the delete's own collectCascadeDeleteSet walk with fileElevations: false, so the "which relations cascade" answer has one source, and an inspection files no delete-elevation record. It reads as system with no organization. That matches the system-context delete the command then runs, and it is the same posture as the existing public inspectDanglingReferences, so no new reach is opened beyond what a plugin holding the engine already has.
  • collectCascadeDeleteSet gains an optional opts with a default that keeps every existing caller's behaviour (fileElevations: true). RIGHT: a private signature, with no published change.
  • os db orphans, a new CLI command. RIGHT as a widening of the operator surface. It follows the ruling's five constraints (6303808312):
    1. explicit, with no automatic caller; pinned by orphans.no-auto-run.test.ts and the one-shot family row;
    2. dry-run by default;
    3. --delete deletes only the same run's listed rows, re-checked just before each delete;
    4. --export is mandatory, created wx and 0600, read back before the first delete; the order is pinned and ablated;
    5. the master-detail's own cascade, through the engine's delete, previewed by the same walk.
  • No accept set of any existing door changes. The audit stays report-only, and the system-write exemption is untouched.

② Semver level

Clause-②: yes (widening)

  • @objectstack/objectql: minor (an additive public method) and @objectstack/cli: minor (a new command), with Clause-②: yes (widening) in both the PR body and the changeset after patch round 1. RIGHT. Check Changeset is success on this head, and the level axis reads the widening arm.

③ Boundary flags

  • The dev's open question (re-declare Clause-②) is answered: yes (widening), applied in patch round 1 (6307363754). The stale body paragraph that round left was corrected by the seat; the read-back is identical.
  • The declared test narrowings are answered by this head's check-runs: objectql targeted families locally, the whole cli unit tier, and the e2e purity spawn. All 56 check-runs are success or roster-skipped (check-expected-skips.mjs --pr 25039: OK, 10 skipped), Test Core included.
  • Out-of-scope notes, accepted as Acceptance notes with no carrier:
    • undetermined is a count, not an itemised list; itemising it would change the audit's published report shape;
    • the master-detail predicate is named isControlledByParentMasterField;
    • HotCRM's 404 premise was not re-measured; the door does not depend on it.

Implemented-by: claude/issue-24964-orphan-detail-operator-command
Reviewed-by: session_01QK6pdbxKZiHdFApL8RYVh9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Seat review · domain:engine seat 2 · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T04:07Z · head 5231f8fd7 · verdict: ACCEPT (TIER_DEFAULT; this lane seat's own review of the Clause-②: yes (widening) declaration, record 6307770129)

  • Shape: a draft PR to main from the claimed branch claude/issue-24964-orphan-detail-operator-command. The body's card keyword is Fixes #24964, so the merge closes the card.
  • Ruling held: each of the five constraints of ruling A (6303808312) has a pin on this head. Explicit command with no automatic caller: the import scan plus the one-shot family row. Dry run by default: the database is byte-identical before and after. --delete deletes only the same run's rows, re-audited just before. A mandatory wx/0600 export is read back before the first delete; the order pin intercepts the first delete(). The master-detail's own cascade runs through the engine's delete(), previewed by the same walk.
  • Diff, read by the seat:
    • inspectCascadeDelete is read-only. It reuses collectCascadeDeleteSet with fileElevations: false, and the default opts keeps every existing caller.
    • It reads as system with no organization, the same posture as the existing public inspectDanglingReferences.
    • No boot, migrate or lifecycle path imports the command.
    • The docs in content/docs/deployment/cli.mdx were checked sentence by sentence. The set_null clearing claim is the engine's own behaviour, pinned in its tests.
  • Gates on this head:
    • 56 check-runs: 46 success, 10 skipped. All 10 skips are in main's roster (check-expected-skips.mjs --pr 25039: OK).
    • check-governed-merges.mjs --pr 25039: NOT governed, +1790/−1.
    • Check Changeset is success: cli and objectql are both minor, with Clause-②: yes (widening) in both the body and the changeset.
  • Accepted deviations:
    • The test runs were narrowed by file name. objectql ran its targeted families locally and the cli integration tier was targeted; CI ran the rest, and Test Core is green on this head.
    • History was deepened for check-engine-split-ratio.
    • Two red lanes on the old head 0770e4fc9 were cancelled by a newer push, not failures.
  • Out of scope (no carrier):
    • undetermined is a count, not an itemised list; itemising it would change the audit's published report shape.
    • The predicate is named isControlledByParentMasterField.
    • HotCRM's 404 premise was not re-measured; the door does not depend on it.
  • Landing: this seat flips the PR to ready and arms auto-merge through the relay now. At the merge, objectql: a master-detail row whose master is missing can be neither read nor removed through any door — the admin answers 404 by id, queries omit it, and the integrity audit only reports it #24964 closes via Fixes and the card's pm:dispatched is removed.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 04:08
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 04:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 157ebd0 Oct 21, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24964-orphan-detail-operator-command branch October 21, 2026 04:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants