Skip to content

πŸ› fix(maturity): stop four identity-drift bugs from silently resetting clocks and re-notifying - #607

Merged
scttbnsn merged 8 commits into
dev/v1.6from
fix/v1.6-rc7-identity-drift
Jul 26, 2026
Merged

πŸ› fix(maturity): stop four identity-drift bugs from silently resetting clocks and re-notifying#607
scttbnsn merged 8 commits into
dev/v1.6from
fix/v1.6-rc7-identity-drift

Conversation

@scttbnsn

@scttbnsn scttbnsn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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 created participating only when there's no digest (registries don't all derive created from 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 stashed updateDetectedAt / firstSeenAt / maturityGatePendingSince. It hashed created unconditionally, so a drifted created across 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: true notification. computeResultHash (app/store/notification-history.ts) hashed suggestedTag and created. Its docstring said it mirrored hasResultChanged() β€” the comparator #568 disproved. A manual "check for updates" bypasses the registry poll cache (the scheduled path passes useRegistryPollCache, the manual one doesn't), so it routinely returns drifted display metadata for an unchanged candidate. New hash, hasAlreadyNotifiedForResult reports "not notified", one-shot notification fires again.

A finishing security scan reverts an update the watcher just found. runBulkScan and persistAndBroadcast captured a container snapshot before the scan and wrote that whole snapshot back with only security changed. Scans run seconds to minutes; a watcher poll in that window could legitimately advance result / updateAvailable / updateDetectedAt / maturityGatePendingSince and 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.ts already re-fetched at write-back; these two didn't.

Containers with no available update get ranked and filtered as if they had one. getContainerUpdateAge had no updateAvailable guard on its fallback, so a container gated by maturityMode surfaced under ?maturity=mature, ?maturity=hot, and ?sort=age while its own updateEligibility blockers in the same response said it was blocked.

How

getCandidateIdentityFields is now exported from app/model/container.ts as the single definition, and all three comparators derive from it. hasCandidateIdentityChanged and getResultSignature keep their exact current behavior (their existing tests pass untouched); only computeResultHash changes semantics. The scan paths re-fetch via getContainerRaw and merge only security, 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.mjs threw RC_VERSION must end in -rc.<number> the moment the version constant becomes a plain 1.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-mismatch shown during the agent trigger-registration window β€” split out of discussion #325, where the original answer was wrong), #606 (manifest created fetch failures swallowed to undefined, 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.7 and restarts the seven-day soak. GA is rc.7's publishedAt + 7 days.

Changelog

  • πŸ› Fixed identity drift across container recreation by centralizing candidate identity comparison around tag+digest, using created only for legacy/no-digest candidates (affects lifecycle caches and result signatures).
  • πŸ› Fixed stale/incorrect notification deduplication by updating result hash semantics: identity fields derive from candidate identity helper and created is hashed only when no digest exists (and removed suggestedTag from the hash payload).
  • πŸ› Fixed security scan persistence/replay issues by re-fetching the current container at write-back time and writing only security data onto the latest record.
  • πŸ› Fixed concurrent watcher race conditions during on-demand security scan persistence (preserve newer watcher fields; skip/avoid resurrecting removed β€œzombie” containers).
  • πŸ› Fixed one-shot update-candidate scan field handling by clearing update-candidate-specific scan/signature/SBOM fields when the candidate identity changes mid-scan, while preserving current-image security results.
  • πŸ› Fixed maturity filtering and sort=age behavior by gating update-age computation and age sorting behind updateAvailable and excluding containers without available updates from maturity/age buckets.
  • πŸ”§ Added/expanded regression tests covering candidate identity drift, concurrent bulk/on-demand scan persistence safety, update-age gating, container lifecycle β€œupdate-clock carry-forward”, notification hash stability, and GA release-docs identity/quickstart matrix assertions.
  • πŸ”§ Updated bulk/on-demand security handler dependencies to support refreshed write-back via getContainerRaw.
  • πŸ”§ Corrected/clarified getContainerRaw documentation.
  • ⚠️ Breaking: Requires runtime v1.6.0-rc.7; restart the seven-day soak. Also requires updating all createSecurityHandlers/createBulkSecurityHandlers callers to provide getContainerRaw.
  • πŸ”’ Security: Prevents security scan write-back from overwriting concurrent updates or persisting results to removed containers.

Concerns

  • Confirm every createSecurityHandlers and createBulkSecurityHandlers call site provides getContainerRaw.
  • Ensure the production rollout includes the required runtime upgrade to v1.6.0-rc.7 and the seven-day soak restart.
  • Verify any custom store implementations correctly return undefined from getContainerRaw when a container is removed/replaced.

scttbnsn added 7 commits July 26, 2026 16:30
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.
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
drydock-website Ready Ready Preview, Comment Jul 26, 2026 10:03pm
drydockdemo-website Ready Ready Preview, Comment Jul 26, 2026 10:03pm

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. πŸŽ‰

ℹ️ Recent review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 86448e57-ac70-4ba2-b4f9-b08b2ed4e429

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between f2f6dd7 and cebf61e.

πŸ“’ Files selected for processing (6)
  • app/api/container/crud.test.ts
  • app/api/container/maturity-filter.test.ts
  • app/api/container/security.test.ts
  • app/api/container/security.ts
  • app/api/container/update-age.ts
  • scripts/release-docs-identity.test.mjs
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/api/container/update-age.ts
  • app/api/container/security.ts

πŸ“ Walkthrough

Walkthrough

Security 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 updateAvailable, with maturity and sorting tests updated. Release documentation tests cover prerelease and GA tag matrices.

Possibly related PRs

πŸš₯ Pre-merge checks | βœ… 2
βœ… Passed checks (2 passed)
Check name Status Explanation
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/v1.6-rc7-identity-drift

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.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
app/api/container/security.test.ts (1)

1122-1146: πŸ“ Maintainability & Code Quality | πŸ”΅ Trivial | ⚑ Quick win

Assert the completion broadcast on the 404 path.

persistAndBroadcast deliberately calls broadcastScanCompleted(id, status) before returning 404 β€” untested here, while bulk-security.test.ts:949 covers 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 value

Silent 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 value

Drop container from persistAndBroadcast.

persistAndBroadcast fetches the current container record internally and does not use options.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 value

Mirror the id argument.

In scanContainer, the write-back re-fetch calls getContainerRaw(id), but this mock calls storeContainer.getContainer() with no argument. A no-op mock passes today, but this can hide an id-aware mock replacement; forward the id kept on req.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

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between ca6212d and f2f6dd7.

β›” Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
πŸ“’ Files selected for processing (15)
  • app/api/container.test.ts
  • app/api/container.ts
  • app/api/container/bulk-security.test.ts
  • app/api/container/bulk-security.ts
  • app/api/container/crud.test.ts
  • app/api/container/filters.test.ts
  • app/api/container/security.test.ts
  • app/api/container/security.ts
  • app/api/container/update-age.ts
  • app/model/container.ts
  • app/store/container.test.ts
  • app/store/container.ts
  • app/store/notification-history.test.ts
  • app/store/notification-history.ts
  • scripts/release-docs-identity.test.mjs

Comment thread app/api/container/security.ts
Comment thread app/api/container/update-age.ts
Comment on lines +4962 to +4963
const db = { getCollection: () => collection, addCollection: () => null };
container.createCollections(db);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread scripts/release-docs-identity.test.mjs Outdated
@scttbnsn

Copy link
Copy Markdown
Contributor Author

Went through all four. Three are real, one isn't.

Gate ordering in update-age.ts: fixed. You're right, and there's a concrete producer rather than just a theoretical stale value. buildFallbackContainerReport (app/watchers/providers/docker/docker-helpers.ts:63) spreads the container, which materializes the updateAge getter into a plain frozen number, then sets updateAvailable = false on the next line. That's exactly the shape you described. Gate moved above the cache read, with a comment explaining why the ordering matters so nobody tidies it back later.

Update-image fields in persistAndBroadcast: fixed. Good catch, and it's a problem this PR introduced. scanUpdateImage computes updateScan/updateSignature/updateSbom against the pre-scan container.result.tag, and re-fetching the live record is what split them apart. Before this change the snapshot and the patch were at least consistently stale together. persistAndBroadcast now compares candidates via hasCandidateIdentityChanged and clears those three keys when the candidate moved, since whatever is already stored describes the superseded candidate too. Current-image scan/signature/sbom still apply, because they describe the running image rather than the candidate.

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 const db at container.test.ts:4962/4989: not a real finding. Those two lines sit in separate test() callbacks (starting at 4952 and 4979), so they're different function scopes, not a redeclaration in one block. The module parses fine: the full app suite runs 12383 tests green, and this PR's own Test & Coverage job passed. Looks like the diff context stitched the two hunks together.

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.
@scttbnsn
scttbnsn merged commit 70b1181 into dev/v1.6 Jul 26, 2026
25 checks passed
@scttbnsn
scttbnsn deleted the fix/v1.6-rc7-identity-drift branch July 26, 2026 22:25
scttbnsn added a commit that referenced this pull request Jul 26, 2026
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
scttbnsn added a commit that referenced this pull request Jul 26, 2026
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 -->
scttbnsn added a commit that referenced this pull request Jul 28, 2026
…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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant