Skip to content

fix(objectql): the Archiver resolves its window through ADR-0057 P4 governance - #10864

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10528-archiver-governance-windows
Aug 21, 2026
Merged

fix(objectql): the Archiver resolves its window through ADR-0057 P4 governance#10864
os-elon merged 1 commit into
mainfrom
claude/issue-10528-archiver-governance-windows

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10528

LifecycleService.archiveObject read archive.after — and, since #10347,
ttl.expireAfter — straight off the declaration. So for either object declaring
lifecycle.archive an operator's settings override was silently ignored, a
registered LifecycleRetentionFloor (#5195) was never evaluated, and per-tenant
windows did not apply.

Which shape, and why

The dispatch asked this to be stated explicitly. Governance is resolved inside
archiveObject
, not in reapObject before it delegates.

The gap was never a forgotten call. reapObjectreturnsthis.archiveObject(...)
when archive is declared, so the three effectiveWindowMs resolutions on the reap
path sit on a branch archive-declaring objects skip entirely — which is why the
divergence was total rather than partial, and why threading an override into the
cutoff line alone would still have left floors and tenant windows unreached.

Resolving it in the caller would have meant either duplicating the selection of
which window is due, or splitting one decision across two methods: that selection
lives in archiveObject by the 2026-08-20 ruling behind #10347. And the per-tenant
leg is a pass over this method's own hot/cold batch loop, which cannot leave it
either way. So archiveObject now owns the archive policy end to end, mirroring
reap()'s internal structure — global window resolved once, tenant windows resolved
inside, one batch pass per window.

Both legs

  • Global override. On the key that matches which window the selection picked:
    expireAfter for a ttl-selected archive, maxAge for an age-selected one — the
    identical mapping reap() makes. Two windows, not two spellings of one.
  • Retention floor. An override below a registered floor is rejected (the declared
    window stands), logged at error naming registrar, consequence and fix, and
    recorded in report.floorViolations. A declared archive window below a floor is
    reported the same way and still enforced.
  • Per-tenant windows. One candidate read per overriding tenant on its own cutoff,
    then one global pass covering everyone else including NULL-org rows (a bare $nin
    skips them). Tenant-scoped overrides go through the same floor, falling back to the
    already-resolved global window.

Unchanged on purpose: #10347's cutoff selection; the retain-first posture
(archive-pending, hot-delete only what the cold store took); the #4747 per-batch
abort checks, now the first act of every pass so the leg boundary widens with the
loop rather than leaving a new unchecked seam between passes; and the cold-side
archive.keep prune, which bounds the archive rather than the hot store and has no
settings key. An object with no override and no floor sweeps as one pass over exactly
the predicate it ran before.

Evidence

All commands at f136a73365. The suite imports the subject relatively
(./lifecycle-service.js) and resolves it through src/, proven in both
directions rather than assumed: the 8 controls below flipped red-to-green from a
src-only edit with no rebuild of packages/objectql/dist between the two runs,
so no dist preflight applies to this pair.

1. Defect controls — red on the pre-fix tree. Predicted before running: 8 of the
10 new cases red, 2 green (the two that assert pre-existing behaviour). Observed
exactly that — Tests 8 failed | 94 passed (102). The floor leg failed on
expected [] to deeply equal [ { object: 'sys_audit_log', …(7) } ], which is the
card's claim: an empty floorViolations is indistinguishable from a healthy sweep.
The tenant leg failed on expected [ Array(1) ] to deeply equal [ {…}, {…} ] — one
global pass where two are due. Post-fix: Tests 102 passed (102).

2. Preserved-behaviour control — falsified by mutating my own fix.#10347's
selection is the most likely casualty of routing this through effectiveWindowMs and
no defect control would catch it. Collapsing the new resolution onto the
retention/created_at pair reddens exactly the predicted four and no others:
DISCRIMINATING CONTROL: a declared ttl decides which rows move, a row whose ttl.field is null or absent is NOT due at the epoch (both pre-existing), plus the two
new key-mapping cases. POSITIVE CONTROL: archive WITHOUT ttl still moves rows by created_at age stayed green as predicted, having no ttl. Restored and proven on
disk, not by an exit code: git hash-object = git rev-parse HEAD:PATH =
ce70017ab9fb25b70ee8e71ccbdc1bfab3580dfb, git diff --exit-code 0,
git status --porcelain empty.

3. The silence itself.SILENCE CONTROL pairs the two non-empty assertions with a
sweep that must stay silent — a floor registered and an override present, both
legal — asserting floorViolations empty and error never called. Without it,
"non-empty" is satisfied by any bug reporting spurious violations.

Expected non-effects, named before the runs and each carrying its own control.
sys_audit_log and sys_metadata_audit are the only two objects declaring
lifecycle.archive; an override naming one must leave the other on its declaration
(asserted, both swept in one pass). The override key that does not govern a given
selection must be ignored in both directions. A tenant override naming a different
object must leave the Archiver on one global pass. The archive.keep cold prune must
stay a single created_at prune even under per-tenant passes. Every ttl-only,
non-archive object must not move at all — carried by the 94 pre-existing cases, all
green throughout.

pnpm --filter @objectstack/objectql testTest Files 226 passed (226),
Tests 4009 passed (4009). pnpm --filter @objectstack/objectql typecheck — clean.

Gates. Union derived with node scripts/pm/dispatch-gates.mjs (no path arguments)
after the final commit; 13 path-matched plus 5 convention-triggered, all run, exits
captured before any pipe. All 18 green. check:type-check-debt refused on an unbuilt
worktree (that throw is not measured, never not applicable), so the closure was
built as lint.yml does before re-running it: 33 ledger entr(ies) re-measured in 236.7s, 1912 raw tsc error(s) total, none above its recorded number. No ledger or
baseline was raised. check-nul-bytes: OK.

Out of scope

#10526 is not addressed here — spec-side, another lane. #10527's triple-declaration
question landed separately in #10643, which now refuses a diverging
retention + ttl + archive triple at parse time; the comment in archiveObject
that still calls it undecided is reported to the PM rather than edited here.


Generated by Claude Code

…#10528)
`archiveObject` read `archive.after` — and, since #10347, `ttl.expireAfter` —
straight off the declaration, so for any object declaring `lifecycle.archive`
an operator's settings override was silently ignored, a registered
`LifecycleRetentionFloor` was never evaluated, and per-tenant windows did not
apply.
The cause is structural, not a forgotten call: `reapObject` returns into
`archiveObject` when `archive` is declared, so the three `effectiveWindowMs`
resolutions on the reap path sit on a branch archive-declaring objects skip
entirely. Governance is therefore resolved inside `archiveObject`, where the
#10347 ruling already put the decision about WHICH window is due — resolving it
in the caller would duplicate that selection or split one decision across two
methods, and the per-tenant leg is a pass over this method's own batch loop
either way.
All three legs now run through the same resolver the Reaper uses: the global
override (on the key matching the selected window), the retention floor
(#5195) for both overrides and declarations, and per-tenant windows as one
candidate read per overriding tenant plus a global pass covering NULL-org rows.
#10347's cutoff selection, the retain-first posture, the per-batch abort checks
and the cold-side `archive.keep` prune are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via expireAfter (literal), maxAge (literal))
  • content/docs/deployment/production-readiness.mdx(via maxAge (literal))
What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 14 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 bfadf84533e3e9868f2952cf4a29329bb814b4c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8f3ce7b8966da874fa15f1ec36341b0185bf4519 — the merge of head f136a7336539b57a19b670b93a688c23a026eb0f into base bfadf84533e3e9868f2952cf4a29329bb814b4c8, 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 8f3ce7b8966da874fa15f1ec36341b0185bf4519 && git checkout 8f3ce7b8966da874fa15f1ec36341b0185bf4519
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bfadf84533e3e9868f2952cf4a29329bb814b4c8 f136a7336539b57a19b670b93a688c23a026eb0f && git checkout -B drift-repro bfadf84533e3e9868f2952cf4a29329bb814b4c8 && git merge --no-ff f136a7336539b57a19b670b93a688c23a026eb0f
node scripts/docs-audit/affected-docs.mjs --json bfadf84533e3e9868f2952cf4a29329bb814b4c8

⚠️ 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 bfadf84533e3e9868f2952cf4a29329bb814b4c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-elon
os-elon marked this pull request as ready for review August 21, 2026 14:43
@os-elon
os-elon enabled auto-merge August 21, 2026 14:43
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 7d483e1Aug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10528-archiver-governance-windows branch August 21, 2026 15:00
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32494319591 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.02s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 21 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(objectql): lifecycle governance window overrides and retention floors never reach the Archiver

2 participants

@os-elon@claude