π fix(maturity): stop four identity-drift bugs from silently resetting clocks and re-notifying - #607
Conversation
getResultSignature guards whether a recreated container inherits its stashed updateDetectedAt, firstSeenAt, and maturityGatePendingSince. It hashed tag + digest + created unconditionally, so a drifting created value discarded the stash and reset the maturity soak to now, right after an update landed. This is the same defect class as #565, surviving in a path #568 never touched. #568 fixed hasCandidateIdentityChanged, which guards the same-container-ID recheck; the recreate path has its own comparison and kept the old behavior. created now participates only when no digest is available, matching hasCandidateIdentityChanged exactly: with a digest present the candidate is already uniquely identified and created is display-only metadata that registries don't all derive from the digest, so it can differ between the pre-delete snapshot and the post-recreate rescan without the candidate changing. Without a digest it stays the sole immutable discriminator. Regression test fails without the source change and passes with it; the companion test pins that a genuine tag change still resets the clock, so the fix cannot degrade into never resetting.
β¦ helper hasCandidateIdentityChanged (model/container.ts), getResultSignature (store/container.ts, just fixed by cbbf1d9), and computeResultHash (store/notification-history.ts) each hand-rolled the same "is this the same update candidate" comparison and had drifted out of sync with each other β the exact defect class #565/#568 exist to fix. Export getCandidateIdentityFields from model/container.ts as the single definition: tag + digest always participate, created only when digest is undefined. Rewire all three consumers to derive from it. Behavior of hasCandidateIdentityChanged and getResultSignature is unchanged (their existing tests pass untouched).
getContainerUpdateAge()'s firstSeenAt/publishedAt/updateDetectedAt fallback ignored updateAvailable entirely, unlike the model layer's getRawUpdateAge() which already short-circuits to undefined when no update is available. Callers in maturity-filter.ts and sorting.ts had no guard of their own, so a container actively blocked by maturityMode=mature could still surface under ?maturity=mature/hot and be ranked by ?sort=age, contradicting its own updateEligibility blockers in the same response. Guard with a plain falsy check (not === false) so updateAvailable left unset (never evaluated) and updateAvailable: false (evaluated and gated) are both treated as "nothing to age", matching the existing convention in model/container.ts. Updates the pinned crud.test.ts fallback test to set updateAvailable: true so it keeps covering the fallback's legitimate case, and adds a regression test proving a gated container (updateAvailable: false) is excluded from both the maturity filter and the age sort.
β¦k clock runBulkScan() and persistAndBroadcast() each captured a Container snapshot before awaiting a vulnerability scan (seconds to minutes for Trivy/Grype plus optional SBOM/signature checks), then wrote back by spreading that stale snapshot with only `security` changed. If a watcher poll legitimately detected a real update while the scan was in flight, updateContainer()'s merge doesn't preserve `result` from the live record, so the write-back silently reverted `result`, `updateDetectedAt`, and `maturityGatePendingSince`. The next poll re-detected the same update and stamped a new timestamp, discarding all elapsed maturity soak time β silently, with nothing logged. Mirror the pattern already used by security/scheduler.ts (scanDigestGroup): re-fetch the current record via storeContainer.getContainerRaw(id) at write-back time and merge only the `security` field onto that live record. If the record is gone (container removed or recreated under a new id) skip the write instead of resurrecting a zombie record. - bulk-security.ts: runBulkScan's per-container write-back now reads getContainerRaw(containerId) and skips silently when gone (mirrors the existing broadcastScanCompleted-then-continue error handling). - security.ts: persistAndBroadcast now reads getContainerRaw(id); when gone it broadcasts completion and returns 404 instead of writing. - container.ts: wires getContainerRaw into the bulk-security dependency composition (security.ts already receives the whole store module, which exposes it). Rewrites the persistence assertions in bulk-security.test.ts and security.test.ts to the fetch-then-merge pattern and adds a regression test per file: start a scan via a deferred promise, mutate the store record mid-flight to simulate a concurrent watcher poll, resolve the scan, and assert the live update fields survive and only `security` changed. Both fail before this fix. Also adds a vanished-container regression test per file, and syncs api/container.test.ts's scanContainer suite to mirror getContainerRaw with getContainer since none of those tests simulate concurrent mutation.
release-docs-identity.test.mjs threw "RC_VERSION must end in -rc.<number>"
as soon as the version constant became a plain GA version (1.6.0, no
-rc.N suffix), which would have broken the v1.6 GA cut. The guard only
existed to build the quickstart negative-lookahead that catches a stale
rc-number row.
Replace the throw with a branch on whether RC_VERSION actually has an
-rc.N suffix:
- Prerelease shape: unchanged behavior β exact "Immutable release
candidate" row match plus the same-prefix negative lookahead for a
stale rc number.
- GA shape: assert the quickstart tag matrix lists the exact GA tag as
its own row (the backtick+pipe delimiters mean an un-bumped
`X.Y.Z-rc.N` row does not satisfy this β not a no-op), and assert no
release-candidate row remains for this release line.
All other assertions in the file (badge, site-config, updates heading,
API version samples, changelog links) were already shape-independent
and needed no change. release-identity.test.mjs required no change:
its `${RC_VERSION}0` boundary check already works for both shapes,
since the boundary regex rejects any extra trailing version character
regardless of whether the base string has an rc suffix.
Verified the GA branch is non-vacuous by setting RC_VERSION to 1.6.0,
fixing only the README badge, and confirming that specific assertion
passed while later assertions against still-un-bumped files kept
failing on real content mismatches.
The two fixes cherry-picked from fix/v1.6-scan-writeback landed without CHANGELOG entries. Adds both under [Unreleased] alongside the two identity-drift entries they belong with β all four are the same defect class and ship as one candidate. Also corrects getContainerRaw's docstring, which still claimed it was "only used by the env reveal endpoint" β the scan scheduler already used it, and the two write-back paths fixed here now do too.
Formatting-only fallout from the getCandidateIdentityFields consolidation: type-vs-value import ordering in notification-history.ts and two line-width wraps in model/container.ts. No behavior change.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. π βΉοΈ Recent review infoβοΈ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: π Files selected for processing (6)
π§ Files skipped from review as they are similar to previous changes (2)
π WalkthroughWalkthroughSecurity scan write-back re-fetches raw containers, preserves concurrent changes, clears stale update-image results when candidates change, and skips persistence for missing records. Candidate identity is centralized for lifecycle signatures and notification hashes. Update-age calculation now requires Possibly related PRs
π₯ Pre-merge checks | β 2β Passed checks (2 passed)
β¨ Finishing Touchesπ Generate docstrings
π§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
π§Ή Nitpick comments (4)
app/api/container/security.test.ts (1)
1122-1146: π Maintainability & Code Quality | π΅ Trivial | β‘ Quick winAssert the completion broadcast on the 404 path.
persistAndBroadcastdeliberately callsbroadcastScanCompleted(id, status)before returning 404 β untested here, whilebulk-security.test.ts:949covers the equivalent. Add the assertion so a regression that drops the broadcast (leaving the UI spinner stuck) fails.π Proposed addition
expect(harness.storeContainer.updateContainer).not.toHaveBeenCalled(); + expect(harness.deps.broadcastScanCompleted).toHaveBeenCalledWith('c1', 'passed'); expect(res.status).toHaveBeenCalledWith(404);π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/container/security.test.ts` around lines 1122 - 1146, Extend the test βskips the write and returns 404 when the container vanished while the scan was in flightβ to assert that the completion broadcast dependency is called with the container id and scan status on the 404 path. Keep the existing no-update and response assertions unchanged, using the harnessβs broadcast mock and the status from createScanResult().app/api/container/bulk-security.ts (1)
183-201: π Maintainability & Code Quality | π΅ Trivial | π€ Low valueSilent skip on vanished container; add a log line.
Every other failure path in this loop logs. A container disappearing mid-scan is currently indistinguishable from a successful persist in the logs, and
broadcastScanCompleted(containerId, scanResult.status)still fires below with a success status.π Proposed change
const current = deps.storeContainer.getContainerRaw(containerId); if (current) { deps.storeContainer.updateContainer({ ...current, security: { ...(current.security || {}), scan: scanResult, }, }); + } else { + deps.log.info( + `Bulk scan skipped persistence for container ${containerId} (record no longer present)`, + ); }π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/container/bulk-security.ts` around lines 183 - 201, Update the write-back branch around getContainerRaw in the bulk security scan loop to add an explicit log when current is absent, identifying the vanished container and skipped persistence. Preserve the existing updateContainer behavior when current exists, and ensure the completion broadcast below does not report a successful persist for a missing container by propagating the appropriate failure status or otherwise distinguishing the skipped write.app/api/container/security.ts (1)
401-401: π Maintainability & Code Quality | π΅ Trivial | π€ Low valueDrop
containerfrompersistAndBroadcast.
persistAndBroadcastfetches the current container record internally and does not useoptions.container, but its type still requires it and the call site passes it uselessly. Remove the field and the call-site argument.π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/container/security.ts` at line 401, Remove the unused container field from the options type and destructuring used by persistAndBroadcast, then stop passing options.container at its call site. Keep the internal current-container fetch and all other options unchanged.app/api/container.test.ts (1)
1727-1733: π Maintainability & Code Quality | π΅ Trivial | π€ Low valueMirror the id argument.
In
scanContainer, the write-back re-fetch callsgetContainerRaw(id), but this mock callsstoreContainer.getContainer()with no argument. A no-op mock passes today, but this can hide an id-aware mock replacement; forward the id kept onreq.params.id.β»οΈ Proposed change
- storeContainer.getContainerRaw.mockImplementation(() => storeContainer.getContainer()); + storeContainer.getContainerRaw.mockImplementation((id: string) => + storeContainer.getContainer(id), + );π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/container.test.ts` around lines 1727 - 1733, Update the getContainerRaw mock implementation in the beforeEach setup to accept the requested id and forward req.params.id to storeContainer.getContainer, preserving the existing write-back re-fetch behavior while supporting id-aware mocks.
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/api/container/security.ts`:
- Around line 410-426: Update persistAndBroadcast to compare current.result.tag
and current.result.digest with the scanned candidate before applying
securityPatch; remove mismatched updateScan, updateSignature, and updateSbom
fields when the live candidate differs, while preserving other security updates
and the existing not-found handling.
In `@app/api/container/update-age.ts`:
- Around line 9-16: Move the updateAvailable falsy check in the update-age
handler before the cached updateAge return, so containers with updateAvailable
unset or false return undefined without maturity filtering or sorting. Preserve
the existing cache behavior for available updates, and add a regression fixture
covering updateAvailable: false together with a populated updateAge.
In `@app/store/container.test.ts`:
- Around line 4962-4963: Remove the duplicate db declaration in the affected
test block around container.createCollections, keeping a single const db per
test. Ensure the remaining declaration is reused by both relevant calls without
changing the test behavior.
In `@scripts/release-docs-identity.test.mjs`:
- Around line 65-70: Update the GA assertions in the release-docs testβs else
branch to reject only the RC tag pattern for the current release line,
`${RC_VERSION}-rc.N`, rather than any `-rc.N` row. Preserve the exact GA-tag row
assertion and allow RC rows belonging to older release lines.
---
Nitpick comments:
In `@app/api/container.test.ts`:
- Around line 1727-1733: Update the getContainerRaw mock implementation in the
beforeEach setup to accept the requested id and forward req.params.id to
storeContainer.getContainer, preserving the existing write-back re-fetch
behavior while supporting id-aware mocks.
In `@app/api/container/bulk-security.ts`:
- Around line 183-201: Update the write-back branch around getContainerRaw in
the bulk security scan loop to add an explicit log when current is absent,
identifying the vanished container and skipped persistence. Preserve the
existing updateContainer behavior when current exists, and ensure the completion
broadcast below does not report a successful persist for a missing container by
propagating the appropriate failure status or otherwise distinguishing the
skipped write.
In `@app/api/container/security.test.ts`:
- Around line 1122-1146: Extend the test βskips the write and returns 404 when
the container vanished while the scan was in flightβ to assert that the
completion broadcast dependency is called with the container id and scan status
on the 404 path. Keep the existing no-update and response assertions unchanged,
using the harnessβs broadcast mock and the status from createScanResult().
In `@app/api/container/security.ts`:
- Line 401: Remove the unused container field from the options type and
destructuring used by persistAndBroadcast, then stop passing options.container
at its call site. Keep the internal current-container fetch and all other
options unchanged.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: daea9e8a-187e-44a7-937b-88ed4fcc919e
β Files ignored due to path filters (1)
CHANGELOG.mdis excluded by!CHANGELOG.md
π Files selected for processing (15)
app/api/container.test.tsapp/api/container.tsapp/api/container/bulk-security.test.tsapp/api/container/bulk-security.tsapp/api/container/crud.test.tsapp/api/container/filters.test.tsapp/api/container/security.test.tsapp/api/container/security.tsapp/api/container/update-age.tsapp/model/container.tsapp/store/container.test.tsapp/store/container.tsapp/store/notification-history.test.tsapp/store/notification-history.tsscripts/release-docs-identity.test.mjs
| const db = { getCollection: () => collection, addCollection: () => null }; | ||
| container.createCollections(db); |
There was a problem hiding this comment.
π― Functional Correctness | π΄ Critical | β‘ Quick win
Remove the duplicate db declarations.
Line 4962 and Line 4989 redeclare const db in the same block, so this test module cannot parse. Keep one declaration in each test.
Proposed fix
const collection = createFilterableCollection([{ data: oldFixture }]);
const db = { getCollection: () => collection, addCollection: () => null };
- const db = { getCollection: () => collection, addCollection: () => null };
container.createCollections(db);Also applies to: 4989-4990
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/store/container.test.ts` around lines 4962 - 4963, Remove the duplicate
db declaration in the affected test block around container.createCollections,
keeping a single const db per test. Ensure the remaining declaration is reused
by both relevant calls without changing the test behavior.
|
Went through all four. Three are real, one isn't. Gate ordering in Update-image fields in GA rc-row check scoping: fixed. Adopted as suggested. The comment above it already claimed "for this release line" while the regex wasn't scoped to one, which is the same code-drifts-from-its-docstring problem this whole PR is about. Re-checked the scoped version against the real un-bumped quickstart to confirm it still fails pre-bump instead of passing vacuously. Duplicate Regression tests for the first two are going in now. |
Three of CodeRabbit's four findings on #607 were real. 1. update-age.ts: the updateAvailable gate ran AFTER reading the cached container.updateAge, so it only covered the fallback path. On a validated container updateAge is a live getter that re-runs getRawUpdateAge and self-gates, but any spread or structuredClone materializes it into a plain frozen number. buildFallbackContainerReport (docker-helpers.ts:63) spreads the container and then sets updateAvailable = false on the next line, producing a stale finite age on a container with no available update. Gate moved above the cache read. 2. security.ts: scanUpdateImage computes updateScan/updateSignature/ updateSbom against the pre-scan container.result.tag. The write-back re-fetch added earlier in this branch makes result live again, so a candidate that moved mid-scan would get the previous candidate's CVEs, signature, and SBOM attached to it. Before the re-fetch the snapshot and the patch were at least consistently stale together; making result live is what split them. persistAndBroadcast now compares candidates with hasCandidateIdentityChanged and clears the three update-image keys when they diverge, matching how scanUpdateImage already clears them when no update is available. Current-image scan/signature/sbom still apply, since they describe the running image, not the candidate. 3. release-docs-identity.test.mjs: the GA branch rejected an "Immutable release candidate" row for ANY version line while its own comment claimed it was scoped to this release line. Scoped the regex to ${RC_VERSION}-rc.N. Re-verified it still fails against the real un-bumped quickstart rather than passing vacuously. The fourth (duplicate `const db` in container.test.ts) was not real: the two declarations are in separate test() callbacks, so they are different function scopes. The module parses and the full suite runs green. Each new test was run against the pre-fix source and observed to fail. Also sets updateAvailable: true on the sort=age fixture, which set updateAge with no updateAvailable and only passed because the cached read came first.
Prepares the release surfaces for cutting `v1.6.0-rc.7`. No runtime code changes. ## Why now rc.6 published clean earlier today, but the same day's identity-drift sweep produced four runtime fixes (#607) plus the icon-bundle regen (#608). A runtime change forces a new candidate and restarts the seven-day soak, so rc.7 it is. ## What's here **Changelog.** Promotes `[Unreleased]` to `[1.6.0-rc.7] β 2026-07-26` so `release-cut.yml`'s CHANGELOG validation resolves an entry for the tag. The four Fixed entries came in with #607; this adds the Changed entry for #608 and the link definitions. **Release identity.** The usual 17-file bump: version badge, `site-config.ts`, the roadmap entry in `site-content.ts`, demo mock fixtures, the API doc response samples, the quickstart tag matrix, a new highlights section on the updates page, and the three pinned identity tests under `scripts/`. The README highlights block keeps the cumulative v1.6 feature list, matching the rc.6 prep pattern (badge + summary line bump only). All eight version files already sit at the `1.6.0` base, so no `package.json` bump is needed. ## Verification - `node --test` on the four identity/changelog scripts: 24 pass, 0 fail - Full pre-push gate green end to end: qlty, qlty-smells, scripts-test, workflow-tests, typecheck-ui, web-scripts-test, coverage, build, zizmor
Routine pre-cut sync: takes `dev/v1.6` (`391c1b7d`) wholesale onto `main` so the rc.7 tag cuts from a tree identical to the dev branch. Delta since the rc.6 sync (#603): - #607 β four identity-drift fixes (soak-clock recreate reset, notification dedup double-fire, scan write-back revert, update-age fallback leak) - #608 β icon-bundle regenerated against the locked iconify versions - #609 β rc.7 release-surface prep (CHANGELOG heading, 17-file identity bump) Head branch is a throwaway (`sync/main-20260726-rc7`), not `dev/v1.6`, so auto-delete-head-branches can't touch the dev branch (#596 lesson). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Changelog - π Fixed identity drift across maturity tracking, notification deduplication, and scan write-back. - π Prevented stale or removed containers from being overwritten or resurrected during scans. - β¨ Added shared candidate-identity extraction and raw-container access APIs. - π§ Updated maturity/age filtering and sorting behavior and expanded regression coverage. - π§ Regenerated the server icon bundle. - π§ Bumped release surfaces, documentation, mocks, and validation tests from `v1.6.0-rc.6` to `v1.6.0-rc.7`. - β¨ Added `v1.6.0-rc.7` release highlights and GA-compatible release-surface checks. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
β¦policy overrides (#624) ## Problem Agent-managed containers lose their controller-set update policy overrides (maturity mode/min-age days, skip lists, snooze) on every agent report. The agent resolves its own declarative (env/label) policy but never learns controller-side runtime overrides, so every container report it sends carries an explicit `updatePolicyOverrides: {}` layer. The controller persisted that layer verbatim, and the store's `updateContainer` treats any *present* `updatePolicyOverrides` key as authoritative β even when empty. Any manual recheck or periodic agent sync wiped the settings. This is the settings-deletion half of #565. The soak-clock resets in that thread were real but separate mechanisms, fixed in #566/#568 and the rc.7 identity batch (#607). The reporter runs the controller-agent architecture (confirmed in their own words in #496, reconfirmed in #623), which is why the symptom survived all four prior fixes: those fixed the clock, this deletes the actual override values β matching the original before/after screenshots. The insert/recreate path has had exactly this protection since #496/#497 (`restoreRetainedUpdatePolicy`: "an empty layer carries no controller intent"); the same-id `updateContainer` path never got it. ## Fix (two layers) 1. **`AgentClient.buildContainerReport`** reapplies the controller's stored runtime overrides onto the incoming agent report before persistence. Covers all five agent ingestion call sites (handshake, single-container, pending watcher-cycle, bulk reports, recheck) β they all funnel through this one method. 2. **`storeContainer.updateContainer`** now encodes the invariant directly, mirroring the insert-path rule: a present-but-empty override layer carries no intent and falls back to the stored overrides, unless the caller passes `authoritativeEmptyOverrides: true`. The update-policy PATCH handler passes it, so deliberate clears from the UI still stick. Safety was verified adversarially before landing: overrides can only be set via the controller's PATCH endpoint, there is no controllerβagent policy push that could echo stale values back, expired snoozes can't be resurrected (evaluated live at read time), and the local (non-agent) watcher path already preserves overrides by construction (it mutates the live store doc). ## Tests - Regression: mature/5-day override survives an agent report with an empty override layer (fails pre-fix) - skipTags/skipDigests/snoozeUntil survive the same scenario - Inverse: cleared overrides stay cleared on subsequent agent reports (no resurrection) - Store guard: authoritative empty clears; non-empty always authoritative; declarative-absent empty preserved; first-write empty stays empty - PATCH handler passes clear authority to the store Full app suite: 394 files / 12,395 tests, 100% coverage. Full lefthook pre-push gate green. Refs #565 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Changelog - π Fixed agent reports clearing controller-owned update-policy overrides. - π§ Reapplied stored maturity, minimum-age, skip-list, and snooze overrides during agent report normalization. - π§ Treated empty override layers as non-authoritative by default during container updates. - β¨ Added explicit `authoritativeEmptyOverrides` support for intentional clears from update-policy PATCH requests. - β¨ Added regression coverage for override preservation, clearing, and PATCH behavior. - π§ Forwarded update options through container API handlers. ## Validation - Full suite passes: 394 files, 12,395 tests, 100% coverage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Four bugs, one root cause, plus the test change that unblocks the GA cut.
The pattern
PR #568 fixed #565 by establishing what "the same update candidate" means: tag and digest, with
createdparticipating only when there's no digest (registries don't all derivecreatedfrom the digest, so it drifts between scans while the candidate stays put). It fixed exactly one comparator.There were three more places making that same judgment, each hand-rolled, each slightly different. When one of these gets it wrong there's no error and no log line, just a clock that quietly restarts or a notification that fires twice.
What's fixed
The maturity soak clock resets when a container is recreated.
getResultSignature(app/store/container.ts) decides whether a recreated container inherits its stashedupdateDetectedAt/firstSeenAt/maturityGatePendingSince. It hashedcreatedunconditionally, so a driftedcreatedacross the recreate window discarded the stash and restarted the soak β right after an update landed, which is precisely when continuity matters. #568 never touched this path.Notification dedup fires a duplicate
once: truenotification.computeResultHash(app/store/notification-history.ts) hashedsuggestedTagandcreated. Its docstring said it mirroredhasResultChanged()β the comparator #568 disproved. A manual "check for updates" bypasses the registry poll cache (the scheduled path passesuseRegistryPollCache, the manual one doesn't), so it routinely returns drifted display metadata for an unchanged candidate. New hash,hasAlreadyNotifiedForResultreports "not notified", one-shot notification fires again.A finishing security scan reverts an update the watcher just found.
runBulkScanandpersistAndBroadcastcaptured a container snapshot before the scan and wrote that whole snapshot back with onlysecuritychanged. Scans run seconds to minutes; a watcher poll in that window could legitimately advanceresult/updateAvailable/updateDetectedAt/maturityGatePendingSinceand have all of it silently rolled back. Next poll re-detects the same update, stamps a fresh timestamp, elapsed soak time gone.app/security/scheduler.tsalready re-fetched at write-back; these two didn't.Containers with no available update get ranked and filtered as if they had one.
getContainerUpdateAgehad noupdateAvailableguard on its fallback, so a container gated bymaturityModesurfaced under?maturity=mature,?maturity=hot, and?sort=agewhile its ownupdateEligibilityblockers in the same response said it was blocked.How
getCandidateIdentityFieldsis now exported fromapp/model/container.tsas the single definition, and all three comparators derive from it.hasCandidateIdentityChangedandgetResultSignaturekeep their exact current behavior (their existing tests pass untouched); onlycomputeResultHashchanges semantics. The scan paths re-fetch viagetContainerRawand merge onlysecurity, skipping the write if the container is gone.Upgrade note
Changing the dedup hash formula means history persisted under the old formula won't match. Users may see one extra notification per update already in flight immediately after upgrading. One-time, self-clearing, and called out in the CHANGELOG so it doesn't read as a new bug.
Also here
scripts/release-docs-identity.test.mjsthrewRC_VERSION must end in -rc.<number>the moment the version constant becomes a plain1.6.0, which would have failed the GA cut. It now branches on version shape. Both GA-branch assertions were checked against the real un-bumped rc.6 quickstart to confirm they actually fail pre-bump rather than passing vacuously.Verification
Every one of the 7 new regression tests was run against pre-fix source and confirmed to fail there, not just to pass here. Full app suite 12383 passing (up exactly 7 from rc.6's 12376). Full pre-push gate green: biome, qlty, qlty-smells, scripts-test, workflow-tests, typecheck-ui, 100% coverage on app and ui, both builds.
Not in scope
Three findings from the same sweep didn't clear the blast-radius bar for a release already soaking, and are filed for v1.6.1: #604 (GHCR auth failures swallowed at debug level; two update-age clocks classifying against the global threshold instead of per-container
maturityMinAgeDays), #605 (agent-mismatchshown during the agent trigger-registration window β split out of discussion #325, where the original answer was wrong), #606 (manifestcreatedfetch failures swallowed toundefined, which is the upstream condition behind two of the bugs fixed here).Release impact
This is a runtime change, so it needs
v1.6.0-rc.7and restarts the seven-day soak. GA is rc.7'spublishedAt+ 7 days.Changelog
tag+digest, usingcreatedonly for legacy/no-digest candidates (affects lifecycle caches and result signatures).createdis hashed only when no digest exists (and removedsuggestedTagfrom the hash payload).sort=agebehavior by gating update-age computation and age sorting behindupdateAvailableand excluding containers without available updates from maturity/age buckets.getContainerRaw.getContainerRawdocumentation.v1.6.0-rc.7; restart the seven-day soak. Also requires updating allcreateSecurityHandlers/createBulkSecurityHandlerscallers to providegetContainerRaw.Concerns
createSecurityHandlersandcreateBulkSecurityHandlerscall site providesgetContainerRaw.v1.6.0-rc.7and the seven-day soak restart.undefinedfromgetContainerRawwhen a container is removed/replaced.