Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): the Archiver resolves its window through ADR-0057 P4 governance - #10864
Conversation
…#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
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 14 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 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
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32494319591 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10528
LifecycleService.archiveObjectreadarchive.after— and, since #10347,ttl.expireAfter— straight off the declaration. So for either object declaringlifecycle.archivean operator's settings override was silently ignored, aregistered
LifecycleRetentionFloor(#5195) was never evaluated, and per-tenantwindows did not apply.
Which shape, and why
The dispatch asked this to be stated explicitly. Governance is resolved inside
archiveObject, not inreapObjectbefore it delegates.The gap was never a forgotten call.
reapObjectreturnsthis.archiveObject(...)when
archiveis declared, so the threeeffectiveWindowMsresolutions on the reappath 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
archiveObjectby the 2026-08-20 ruling behind #10347. And the per-tenantleg is a pass over this method's own hot/cold batch loop, which cannot leave it
either way. So
archiveObjectnow owns the archive policy end to end, mirroringreap()'s internal structure — global window resolved once, tenant windows resolvedinside, one batch pass per window.
Both legs
expireAfterfor a ttl-selected archive,maxAgefor an age-selected one — theidentical mapping
reap()makes. Two windows, not two spellings of one.window stands), logged at
errornaming registrar, consequence and fix, andrecorded in
report.floorViolations. A declared archive window below a floor isreported the same way and still enforced.
then one global pass covering everyone else including NULL-org rows (a bare
$ninskips 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-batchabort 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.keepprune, which bounds the archive rather than the hot store and has nosettings 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 throughsrc/, proven in bothdirections rather than assumed: the 8 controls below flipped red-to-green from a
src-only edit with no rebuild ofpackages/objectql/distbetween 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 onexpected [] to deeply equal [ { object: 'sys_audit_log', …(7) } ], which is thecard's claim: an empty
floorViolationsis indistinguishable from a healthy sweep.The tenant leg failed on
expected [ Array(1) ] to deeply equal [ {…}, {…} ]— oneglobal 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
effectiveWindowMsandno defect control would catch it. Collapsing the new resolution onto the
retention/
created_atpair 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 twonew key-mapping cases.
POSITIVE CONTROL: archive WITHOUT ttl still moves rows by created_at agestayed green as predicted, having nottl. Restored and proven ondisk, not by an exit code:
git hash-object=git rev-parse HEAD:PATH=ce70017ab9fb25b70ee8e71ccbdc1bfab3580dfb,git diff --exit-code0,git status --porcelainempty.3. The silence itself.
SILENCE CONTROLpairs the two non-empty assertions with asweep that must stay silent — a floor registered and an override present, both
legal — asserting
floorViolationsempty anderrornever 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_logandsys_metadata_auditare the only two objects declaringlifecycle.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.keepcold prune muststay a single
created_atprune 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 test—Test 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-debtrefused on an unbuiltworktree (that throw is not measured, never not applicable), so the closure was
built as
lint.ymldoes 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 orbaseline 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+archivetriple at parse time; the comment inarchiveObjectthat still calls it undecided is reported to the PM rather than edited here.
Generated by Claude Code