Skip to content

Promote develop to main - #516

Merged
37 commits merged into
mainfrom
develop
Sep 1, 2026
Merged

Promote develop to main#516
37 commits merged into
mainfrom
develop

Conversation

@lopugit

@lopugit lopugit commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Standing promotion PR opened by the Promote develop to main workflow. Its head is develop, so every new push or merge to develop shows up here on its own. Merge it whenever main should catch up — the workflow opens the next one after the following push to develop. The Sync main into develop workflow levels develop with main again after each promotion.

📋 What this promotion carries

5 pull requests merged into develop (37 commits) will land in main when this PR merges — newest first:

PR Title Author Source branch Merged (UTC)
#412 feat: add secure ChatGPT Thingtime plugin lopugit codex/thingtime-chatgpt-plugin 2026-08-26
#535 Sync main into develop lopugit sync/main-into-develop 2026-09-01
#527 Sync main into develop lopugit sync/main-into-develop 2026-09-01
#531 fix(ci): accept durable Feature Stack run IDs on develop lopugit codex/feature-stack-run-id-develop 2026-09-01
#496 Composer: toggle type badges, edit-mode media adding, inline URL adder, 📦 Things rename lopugit claude/post-editor-media-badges-7b2acd 2026-08-31
Direct commits on `develop` without a merged PR (19)
  • 96b352f8 Merge remote-tracking branch 'origin/main' into develop
  • 7ac74ecd Merge remote-tracking branch 'origin/main' into develop
  • bf9ed9de Merge remote-tracking branch 'origin/main' into develop
  • dc052c6b Merge remote-tracking branch 'origin/main' into develop
  • 364769a7 Merge remote-tracking branch 'origin/main' into develop
  • f813fb9f Merge remote-tracking branch 'origin/main' into develop
  • 701faeb6 Merge remote-tracking branch 'origin/main' into develop
  • bdaf7cb5 Merge remote-tracking branch 'origin/main' into develop
  • e975b8f9 Merge remote-tracking branch 'origin/main' into develop
  • 7cb88b07 Merge remote-tracking branch 'origin/main' into develop
  • dcdd7491 Merge remote-tracking branch 'origin/main' into develop
  • ff767ccd Merge remote-tracking branch 'origin/main' into develop
  • 88bd5d05 Merge remote-tracking branch 'origin/main' into develop
  • ec5b3e51 Merge remote-tracking branch 'origin/main' into develop
  • 98c66b07 Merge remote-tracking branch 'origin/main' into develop
  • d1971e10 Merge remote-tracking branch 'origin/main' into develop
  • dfafac87 Merge remote-tracking branch 'origin/main' into develop
  • 0d54ee2b Merge remote-tracking branch 'origin/main' into develop
  • 27e4c854 Merge pull request fix(graphify): bound portable snapshot retention #522 from lopugit/codex/graphify-bounded-snapshots

Auto-maintained by the Promote develop to main workflow — refreshed from develop @ f31864b2 (2026-09-01). Delta comments below track when entries enter or leave the promotion window.

Lopu replay and others added 7 commits August 31, 2026 15:27
…inline URL adder, 📦 Things rename

- Post-type badges are now additive toggles: Text is the always-on base;
  Photos, Marketplace, and Things each switch their field group on top
  without deselecting the others (clicking Text switches them all off).
  The stored crystal type is derived from the live toggles
  (things > marketplace > photos-with-visual-media > text), so the server
  vocabulary is unchanged. The media panel only shows when Photos is on.
- Edit mode mounts the live upload panel again: PATCH /api/v1/things
  attachmentIds is upgraded from a pure-permutation reorder to a sync —
  the full desired display order that must cover every bound id (removals
  still rejected) and may append newly uploaded ready drafts, which are
  bound with the same owner/purpose fences create-time binding uses
  (plus an owner-fenced post-family target check so an edit can never
  deface another owner's thread).
- The linked-image URL adder moves below the upload grid as a single
  inline input with an Add button: each valid URL becomes a grid tile and
  the field clears for the next one (Enter works, multi-URL paste splits).
- 🌀 Thingtime badge renamed to 📦 Things (POST_TYPE_META, composer
  eyebrows, feed filter chips follow).

Verified: attachments + things unit suites green (planAttachmentSync +
sync-kind tests added), Vite client build green, live browser pass on the
worktree dev stack at desktop + 375px mobile (toggle combos, URL adder,
create, edit-add-linked-image, Things sheet). Upload binding on edit is
unit-tested; local stack has no S3, so the upload-complete-then-save flow
needs a preview/prod pass per TESTING.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-visible pending; hidden-tolerant PATCH sync

Round 2 of the composer overhaul (owner QA feedback on PR #496):

- URL media now mints real linked-attachment things (POST
  /api/v1/attachments/link): attachmentLinked root marker, linked/<id>
  object key, zero object bytes, url-bearing crystal with a DECLARED render
  hint, ready at mint, moderation stamped 'skipped'. Accounting treats the
  shape as a closed variant (partial/forged combinations fail closed); only
  doc bytes hit quota; the server never fetches the URL. Duplicate URLs are
  deliberately allowed. Rate-limit row, import-map entry, and apiDocs entry
  (docs registry = Nitro registration + capability feature) included.
- Lifecycle: cleanupClaimedDoc takes a lazy S3 getter and short-circuits
  linked docs straight to the transactional remove+refund, so delete/cancel/
  reap/cascade/session-sweep work without S3; the content endpoint 302s
  linked ids to their external URL as a renderer fallback; the analyzer
  skips linked docs.
- Composer: the add-by-URL input lives INSIDE the Media & files panel below
  the grid (Add button; clears per add; probe demotes extensionless URLs to
  file when they fail to load as images); linked entries share the uploads
  list so reorder/snapshot/markCommitted/remove behave identically; the
  panel stays usable before upload approval. Legacy crystal.images seed as
  local linked tiles on edit and migrate to linked attachments on save; new
  posts never write crystal.images. LinkedImageGallery removed from the
  composer.
- Renderers (card gallery, lightbox, media page, reorder gallery, layout
  canvas) use crystal.url directly for linked media; linked file rows and
  downloads open the original URL in a new tab.
- Moderation fixes for the vanished-image + edit-409 report: owners now see
  their own PENDING attachments (pending: true → "Checking…" badge; blocked
  stays hidden for all), and planAttachmentSync exempts moderation-hidden
  bound ids from the cover requirement, re-stamping them after the requested
  order inside the bind transaction.

Verified: full test:unit green (new suites: linked crystal canonicalization,
closed-union accounting, ownerView pending, hidden-tolerant sync, extension
table client/server pin), build:client green, live E2E on the worktree stack
(duplicates, pdf file-row + download, probe path, post render from external
URLs, edit-add-URL save via PATCH sync, legacy 4-image migration, linked
mint/delete with no S3 configured, desktop + 375px mobile). Detailed note in
PRs/496-claude-post-editor-media-badges-7b2acd--*.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atomic S3 failure, name round-trip, mint compensation, seed cap)

Confirmed by the 12-agent adversarial review of a6409b8:

- The content endpoint now 404s linked attachment ids instead of 302ing to
  crystal.url — the redirect made the first-party content URL an open
  redirect to attacker-chosen origins (CWE-601). Renderers always use
  crystal.url directly, so nothing needed the fallback.
- cleanupClaimedDoc resolves S3 BEFORE the destructive deleting claim for
  non-linked docs (attachmentLinked is immutable, so the pre-claim doc is
  authoritative): an unconfigured/broken S3 fails atomically again instead
  of half-deleting a mixed linked+uploaded cascade and stranding uploaded
  docs in an endless deleting retry loop. Linked docs still need no S3.
- linkedAttachmentNameForUrl re-validates after the 255-char slice (trim,
  control chars, well-formed unicode; falls back hostname → 'linked-media')
  so exotic basenames can no longer produce a non-canonical crystal that
  fails the mint. Round-trip pin test added.
- A linked mint whose tile was removed mid-flight now fires a compensating
  delete instead of orphaning the draft until the 24h reap.
- Legacy image seeds cap at the remaining attachment slots so a >25-media
  legacy post cannot 400-loop on every save.

Accepted-by-design (documented in the PR note): linked mints skip the beta
upload-approval gate and byte moderation — exact parity with the legacy
crystal.images flow they replace; pre-hygiene URLs that fail today's
canonicalizer drop on edit-save exactly as the old composer's client filter
already did.

Verified: test:attachments 134/134 green, lint green, build:client green,
live check — content?id=<linked> now 404s while the card keeps rendering
from the external URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gallery-search

# Conflicts:
#	remix/CHANGELOG.md
…gallery-search

# Conflicts:
#	remix/CHANGELOG.md
#	remix/app/components/Feed/PostComposer.tsx
…7b2acd

Composer: toggle type badges, edit-mode media adding, inline URL adder, 📦 Things rename
@lopugit lopugit added the no-ai-rebase Opt this PR's head branch out of AI history rewriting; the merge resolver owns its conflicts label Aug 31, 2026
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
thingtime Building Building Sep 1, 2026 12:46pm UTC
thingtime (develop) Ready Ready Preview Sep 1, 2026 12:46pm UTC

Request Review

@github-actions github-actions Bot added lopu: mergeable The PR branches can currently be merged without conflicts lopu: overlapping files This PR changes files also changed by another open PR labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review

Lopu reviewed this PR against main as Thingtime's principal PR and repository manager. Using Claude Opus 5.

Lopu found no justified local change to publish from this review pass.

Lopu review — PR #516 · developmain (standing promotion)

What I compared

f31864b2 (develop) against 4adda985 (main), which is also the merge base —
main is a strict ancestor, so this is a fast-forwardable promotion.

The raw diff reads as 51 files / 278k insertions / 305k deletions, but almost
all of that is graphify-out/ snapshot churn. Excluding generated output, the
entire promotion is two files and 75 lines:

 remix/package.json            |  3 +-
 scripts/graphify-cas.test.mjs | 73 +++++++++++++++++++++++++++++++++++++++++++

i.e. a new test:graphify-cas script wired into test:unit, plus the test it
runs. Everything else develop carried has already been promoted.

Review

The new test — "a merge unions branch snapshots and pruning restores the
retention bound" — is a good one, and it documents in executable form the exact
behaviour that PRs #574 and #565 exist to address: two branches each prune to
their own single snapshot, the snapshots are content-addressed to different
source fingerprints, so the merge unions two distinct trees and nothing
re-applies the bound. It then asserts that the richer branch snapshot stays
active across the merge and that pruning restores the limit.

delete process.env.GRAPHIFY_SNAPSHOT_RETENTION at module scope is the right
call and worth calling out: the variable is a documented operator override, so
an ambient value would silently redefine what every retention assertion in the
file means. Deleting it once, for this process and its children, pins the
default instead of asserting against whatever policy the caller exported.

Promotion risk is low: main is an ancestor, all 85 checks are green, and the
source delta is a test plus its wiring.

One thing worth a decision (not a blocker for this promotion)

This wires remix's test:unit chain to ../scripts/graphify-cas.test.mjs
the product copy of the module, which has drifted 301 lines from the
control-plane copy at .github/scripts/graphify-cas.mjs (no
normalizeGraphifyScopeArgs, and none of PR #576's nested-checkout fix). CI
always uses the control-plane copy, so nothing here is incorrect, and adding
coverage is a clear improvement over none. But the product copy is what
scripts/graphify — the wrapper the README tells developers to use locally —
actually runs, and it is now the copy the unit suite pins. Two same-named
modules with a real behavioural gap between them is a maintenance hazard worth
resolving in one direction or the other.

Validation run

Command Result
node --test scripts/graphify-cas.test.mjs on f31864b2 15/15 pass
git diff main..develop -- . ':(exclude)graphify-out' 2 files, 75 lines
git merge-base --is-ancestor main develop true — clean fast-forward
all 85 PR checks pass
CodeQL open alerts on this head 0 — no dispositions written; 516.json left []

Changes made

None. The promotion is clean and safe to merge.

View Lopu workflow run

@github-actions github-actions Bot added lopu: unknown state GitHub is still computing the PR branch state lopu: conflicting GitHub reports merge conflicts for the current PR snapshot lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue and removed lopu: mergeable The PR branches can currently be merged without conflicts lopu: unknown state GitHub is still computing the PR branch state labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ⚠️ Resolver needs attention

Current phase: The detailed Lopu result explains the stopped phase

Estimated completion: Done — no further active-work ETA.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-08-31 22:02 UTC (UTC+00:00) 2026-08-31 15:02 PDT (UTC-07:00) 2026-09-01 08:02 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 40
Repository Conflicting 30
Repository Out-of-date with target 0
Repository GitHub state unknown 1
Repository Part of an open stack 0
Repository Touch files changed by another open PR 37
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 29
This resolver batch Currently resolving 1
This resolver batch Waiting 22
This resolver batch Finished 6

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: 67 changed files are also touched by #10, #54, #57, #68, #72, #74, #79, #92, #93, #101, #105, #107, +17 more.

Exact branch pair: maindevelop.

Timeline

  • 13:01 UTC — Detected conflicts between main and develop; assigning the exact snapshot to the resolver queue.
  • 13:02 UTC — The immutable head/base selection is reserved; waiting for its worker job to enter the serialized lane.
  • 13:03 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 13:14 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:24 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:35 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:45 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:55 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:06 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:16 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:26 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:37 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:47 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:58 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 15:08 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 15:18 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 15:29 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 15:39 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 15:50 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:00 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:10 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:21 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:31 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:41 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 16:52 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 17:02 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 17:13 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 17:23 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 17:30 UTC — Preparing the isolated repository environment.
  • 17:33 UTC — Using repository context to resolve the conflict semantics.
  • 17:34 UTC — The resolver worker finished with failure; its detailed result is posted on this PR.
  • 21:14 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 21:24 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 21:35 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 21:39 UTC — Preparing the isolated repository environment.
  • 21:41 UTC — The resolver worker finished with failure; its detailed result is posted on this PR.
  • 21:52 UTC — The resolver worker finished with failure; its detailed result is posted on this PR.
  • 22:02 UTC — The resolver worker finished with failure; its detailed result is posted on this PR.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with main — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • remix/CHANGELOG.md

@github-actions github-actions Bot added lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue and removed lopu: queued The current PR snapshot is waiting in Lopu's PR-management queue labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with main — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • remix/CHANGELOG.md

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected an out-of-date PR branch

Status: Work detected — Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 11:01 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-01 10:41 UTC (UTC+00:00) 2026-09-01 03:41 PDT (UTC-07:00) 2026-09-01 20:41 AEST (UTC+10:00)
Estimated finish 2026-09-01 11:01 UTC (UTC+00:00) 2026-09-01 04:01 PDT (UTC-07:00) 2026-09-01 21:01 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 40
Repository Conflicting 29
Repository Out-of-date with target 2
Repository GitHub state unknown 0
Repository Part of an open stack 0
Repository Touch files changed by another open PR 37
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 3
This resolver batch Currently resolving 0
This resolver batch Waiting 3
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: maindevelop.

Timeline

  • 10:41 UTC — Detected that main needs to be merged into develop; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu review — this promotion carries no product change.

The full tree difference between develop and main is 53 files, every one under graphify-out/:

$ git diff c23a872...HEAD --name-only | awk -F/ '{print $1}' | sort | uniq -c
     53 graphify-out

No application code, no docs, no config, no tests. The 2.35M-line diffstat is entirely content-addressed Graphify cache and snapshot artifacts; the only three deletions are graphify-out/cache/semantic-cas/** entries.

So main and develop are already identical for everything that ships. Good state to be in — but it means this should be read as a snapshot sync rather than a release: no product risk to weigh, and no product benefit gated behind merging it.

The generated artifacts are correctly marked -merge in .gitattributes and are content-addressed, so the merge can't silently text-merge two JSON graphs.

Checks: Find merge-conflicting PRs was failing at review time with the same GitHub HTTP 502 outage documented on #557 — retried four times, failed closed by design. Not attributable to this PR.

One observation at the repository-direction level, not a request: committing ~47 MB of regenerated Graphify output on every branch is what makes each PR in this batch look enormous and makes review diffstats unreadable. It's clearly a deliberate convention — .gitattributes and .gitignore both encode it carefully — so I've changed nothing, but it was the single biggest source of noise across all ten PRs I reviewed today.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected an out-of-date PR branch

Status: Work detected — Lopu is taking ownership.

Current phase: Entering Lopu's serialized PR-resolution queue.

Estimated completion: around 11:08 UTC (~20 minutes; this adjusts as the queue moves).

Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-01 10:48 UTC (UTC+00:00) 2026-09-01 03:48 PDT (UTC-07:00) 2026-09-01 20:48 AEST (UTC+10:00)
Estimated finish 2026-09-01 11:08 UTC (UTC+00:00) 2026-09-01 04:08 PDT (UTC-07:00) 2026-09-01 21:08 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 39
Repository Conflicting 28
Repository Out-of-date with target 2
Repository GitHub state unknown 0
Repository Part of an open stack 0
Repository Touch files changed by another open PR 36
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 1
This resolver batch Currently resolving 0
This resolver batch Waiting 1
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: maindevelop.

Timeline

  • 10:48 UTC — Detected that main needs to be merged into develop; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🦄 Lopu review — this promotion carries no product change; it's pure Graphify snapshot

Targeting main is correct here — this is the named exception in the AI_ALL.md push
policy. No changes made.

The 2.35M reported additions read alarming, so worth stating plainly: main and develop
are identical in application code, docs, workflows, and configuration. Restricted to
non-generated paths the diff is empty, and every one of the 53 changed files is under
graphify-out/:

$ git diff --name-only 53c461c7 dc052c6b | sed 's#/.*##' | sort | uniq -c
     53 graphify-out

Every product change has already been promoted. What remains is exactly the tracked
artifact class AI_ALL.md calls for — the immutable source-fingerprint snapshot plus
additive semantic-cache variants — with no mutable root graph/manifest/report/cost files
included. Healthy state, not an un-reviewed 2M-line promotion.

ℹ️ One develop-side cleanup worth doing before the next promotion

I installed and typechecked this tree directly to establish the baseline for this review
round: pnpm exec tsc --noEmit → 154 errors, all pre-existing (74 in
app/smarts/index.tsx, 16 in app/schemas/registry.ts, then a long tail).

But remix/scripts/typecheck-baseline.json still reads 143, so the ratchet is emitting a
non-blocking warning against a stale baseline on develop itself. That cost me real time
this round — it made PR #485 look like it had regressed the typecheck when 16 of its 20
flagged errors were develop's, merely shifted down by added lines.

Either close the 11-error gap or re-lock the baseline
(node scripts/typecheck-ratchet.mjs --update-baseline) so the signal means something
again. That belongs on develop, not on this promotion branch, so I've left it alone.

No failing checks. mergeStateStatus: BLOCKED is purely the nine still-queued required
checks; mergeable is MERGEABLE.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu review — this promotion would have tripled main's tracked Graphify tree

All checks pass and the merge is clean, so this is not a CI failure. The problem is in what the promotion carries.

Although the changelog lists four PRs, main already contains all of that code — git merge-base confirms main is fully contained in develop. The entire delta is generated Graphify output: 2,351,339 insertions across 53 files, including two complete ~45 MB snapshot trees.

Ref Snapshots Tracked bytes under graphify-out/snapshots
main @ 53c461c7 (base) 1 47,470,533
develop @ dc052c6b (head) 3 139,883,249

main was correct — 1 snapshot / 47 MiB is exactly the state #522 established one day ago when it cut 52 snapshots / 2.15 GiB down to 1 / 47 MiB. Merging this as-is would have put main back at 3 snapshots / 133 MiB and resumed the growth curve that produced the 2.15 GiB tree.

Root cause

The retention logic is fine — pruneSnapshots runs on every mutation and on ensure, and snapshotRetentionLimit fails closed. The leak is structural:

Snapshots are content-addressed at snapshots/v1/<source-fingerprint>/<artifact-hash>/, so two branches that each refreshed their graph hold snapshots at different paths. .gitattributes marks graphify-out/snapshots/** -merge, so the merge is conflict-free by design — git just unions both trees. Each side was bounded to 1 when it was built; nothing re-applies the bound to the merge result.

Confirmed by history: all three snapshots entered develop through merge commits, not through the chore(graphify): refresh … commits that created them. I reproduced it from scratch — two branches each correctly bounded at 1, one clean conflict-free git merge → 2 tracked snapshots.

Why no test caught it

scripts/graphify-cas.test.mjs test 14 ("independent branch snapshots merge without generated-file conflicts") merges two branches and asserts both snapshots survive. It validates conflict-freedom and never re-checks retention. Retention was only ever asserted on the single-branch mutation path.

Changes on this branch

  1. Restored the bound with the repo's own fail-closed command, npm run graphify:prune{"retention":1,"retained":1,"removed":2}. It kept the highest-quality snapshot (673aee29…, 32,458 nodes / 78,012 links, vs 32,424 and 32,146) and removed the two superseded trees, which stay recoverable from git history by design. The reusable semantic CAS is untouched — all 835 entries intact, as fix(graphify): bound portable snapshot retention #522 intended. This promotion now hands main a 1-for-1 snapshot replacement instead of tripling it.
  2. Closed the test gap — added "a merge unions branch snapshots and pruning restores the retention bound", which merges two branches, asserts the tree exceeds snapshotRetentionLimit(), then asserts pruning restores it exactly while keeping the active snapshot.

No production logic changed; the retention implementation is correct and editing it would have been churn.

Durable fix (separate PR)

The real gap is in the controller, so it is not on this branch. sync-main-into-develop.yml merges main into develop and pushes without ever re-applying the bound. I've prepared a fix against github-actions that re-applies it inside the merge step before candidate_sha is published — reading the router from the protected branch (product-branch code must never execute in that credentialed job), scoping staging to graphify-out/snapshots, and failing soft so it can never block maindevelop sync. The conflict path already rebuilds Graphify, so only the clean path was unguarded.

Validation

  • npm run test:graphify-cas — 14/14 before, 15/15 after
  • Retained snapshot verified: 1 valid record, all 5 portable files; semantic CAS 835/835
  • Worktree scope: only the 2 pruned snapshot trees + the test file
  • Control-plane: YAML parses, workflow-control-plane-contract.mjs --self-test OK, merge step bash -n OK
  • Bug reproduced on a scratch origin (1 → 2 on a clean merge); the fix — run verbatim from the edited workflow — returned it to 1 with a clean tree, merge parents preserved, and a successful push; fail-soft path verified with the protected branch unreachable

No correctness, security, crash, or data/API-compatibility issues in the promoted content — the delta contains no source changes at all.

🤖 Posted by Lopu, Thingtime's PR manager.


⚠️ Update (later Lopu round) — two claims above are now wrong; the original text is kept for the record.

  1. main is no longer at 1 snapshot / 47 MiB. Its tip 4adda985 merged the feature-stack
    branch (PR Feature Stack: Lopu auto stack merge test 1 -> main #566) and is now at 26 snapshots / ~1.13 GB. Don't read the table above as
    current state.
  2. The durable fix is not in sync-main-into-develop.yml — that workflow has no Graphify
    step at all. The real root cause is .github/scripts/stage-graphify-snapshots.mjs, which
    stages only files that still exist, so pruned snapshot deletions never reached the index and
    every controller commit re-tracked them. That fix is now staged against github-actions.

Full diagnosis, reproduction, and validation: #516 (comment)

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📋 Promotion changelog updated — now carrying 5 PRs (33 commits).

Added:

The full changelog lives in the PR description.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ✅ Lopu finished — this PR is mergeable

Current phase: GitHub verified the published branch result

Estimated completion: Done — no further active-work ETA.

Time conversion (UTC source)

Moment UTC Los Angeles Melbourne
Updated 2026-09-01 14:25 UTC (UTC+00:00) 2026-09-01 07:25 PDT (UTC-07:00) 2026-09-02 00:25 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 14
Repository Conflicting 7
Repository Out-of-date with target 2
Repository GitHub state unknown 0
Repository Part of an open stack 0
Repository Touch files changed by another open PR 36
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 2
This resolver batch Currently resolving 0
This resolver batch Waiting 0
This resolver batch Finished 2

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: main is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: maindevelop.

Timeline

  • 12:28 UTC — Detected that main needs to be merged into develop; assigning the exact snapshot to the resolver queue.
  • 12:29 UTC — The immutable head/base selection is reserved; waiting for its worker job to enter the serialized lane.
  • 12:30 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 12:41 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 12:51 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:01 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:12 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:22 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:33 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:43 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 13:53 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:04 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:14 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 14:25 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu review — found the durable root cause of the Graphify snapshot growth: it's in the stager, and it's fixed on github-actions

No failing checks (16 pass / 23 skipping / 9 queued; BLOCKED is just the queued required contexts). CodeQL snapshot for this head is empty. This promotion carries no product change — restricted to non-generated paths the diff is empty.

⚠️ Correcting my earlier round on this PR

My 11:40 comment said main was healthy at 1 snapshot / 47 MiB and attributed the durable fix to sync-main-into-develop.yml. Both parts are now wrong:

Ref Snapshots Tracked bytes under graphify-out/snapshots
main @ 53c461c7 1 47,470,533
develop @ 7ac74ecd (after that manual prune) 1 46,232,636
main @ 4adda985 (this PR's base, now) 26 1,129,716,437
develop @ 96b352f8 (this PR's head) 26 1,128,478,540

4adda985 merges fc5b14d3 (1 snapshot) with b34043d3 (26) — the feature-stack branch promoted by #566. The regression re-entered through the feature-stack promotion, not the develop sync. And sync-main-into-develop.yml has no Graphify step at all, so a fix there would have done nothing.

Root cause

Retention has been correct in the router and dead at staging. graphify-cas.mjs prunes superseded trees from the worktree; .github/scripts/stage-graphify-snapshots.mjs then decides what reaches the commit, and it only ever walked files that still exist:

const snapshots = filesUnder(path.join(output, "snapshots", "v1")).filter(...)
addExisting(root, [...snapshots, ...semantic], { force: true })

A pruned tree isn't in that list, so it's never passed to git add and the deletion is never staged. Every controller-authored Graphify commit re-tracked exactly what the router had just pruned, and left the removals as unstaged worktree deletions.

Reproduced on a scratch repo — two tracked snapshot trees, one removed as pruneSnapshots would:

before fix: controller commit still tracks 5/5 files of the pruned tree, 5 unstaged deletions left behind
after  fix: pruned tree 0 files, retained tree 5 files, `git status -- graphify-out` clean

That's why manual prunes keep working once and evaporating. The bound could never land in Git.

Fixed in the protected control plane, not here — per the control-plane rule it's staged in the trusted checkout for its own PR against github-actions. Scoped to graphify-out/snapshots/v1 only (the semantic CAS is deliberately never pruned), and fail-closed: if zero portable snapshot files remain, it stages nothing, because the router always activates before pruning — an empty namespace means a failed build, not a legitimate prune. I extended the stager's --self-test to cover it and verified the new test fails when the fix is reverted (actual: '' against the expected D entry).

Changes on this branch

Small, both about the test this PR adds:

  1. The new test was coupled to ambient operator config. snapshotRetentionLimit() defaults its argument to process.env.GRAPHIFY_SNAPSHOT_RETENTION, and the test called it bare. Under the documented override it asserted something different:

    GRAPHIFY_SNAPSHOT_RETENTION=2 node --test scripts/graphify-cas.test.mjs
    not ok 5  - snapshot retention defaults to one and rejects unsafe values
    not ok 15 - a merge unions branch snapshots and pruning restores the retention bound
    

    The suite now clears the variable once after its imports. That keeps test 5's intent intact — it exists to assert the default, so pinning an explicit value would have defeated it.

  2. The suite never ran in CI. scripts/graphify-cas.test.mjs wasn't reachable from any job, so this PR was adding a test that couldn't gate anything. remix's test:unit already pulls in the sibling root suite via test:vercel-rootnode --test ../scripts/vercel-root-flow.test.mjs; added test:graphify-cas the same way and wired it in.

 remix/package.json            | 3 ++-
 scripts/graphify-cas.test.mjs | 6 ++++++

No production logic touched — the retention implementation is correct and editing it would be churn.

Why I did not prune this branch

I did this last round when main was healthy and the prune protected it. It's a different call now: main is already at 26, so pruning here means folding a ~1 GB deletion across 25 snapshot trees into a promotion you merge. Until the staging fix is live that's a treadmill — the next feature-stack promotion re-inflates it. Ordering matters: land the controller fix, then one publish restores the bound and it sticks. Widening a routine review into that deletion is your call, not mine, so I've flagged it instead of doing it.

Worth noting this promotion doesn't regress anything: the head is ~928 KB smaller than its base, a 1-for-1 snapshot replacement. All 26 committed snapshots validate as well-formed records.

Validation

  • node --test scripts/graphify-cas.test.mjs15/15, and 15/15 at GRAPHIFY_SNAPSHOT_RETENTION = unset, 2, 7, 0, all (was 13/15 at 2)
  • npm run test:graphify-cas from remix/ → 15/15, confirming the wiring resolves from CI's working directory
  • Snapshot store integrity via the router's own listSnapshots/selectSnapshot: 26/26 valid
  • Control plane: stager --self-test OK and proven to fail without the fix; graphify-cas 20/20; workflow-control-plane-contract.mjs --self-test OK; git diff --check clean

One more thing

.github/scripts/graphify-cas.mjs and product scripts/graphify-cas.mjs have drifted — 277 differing lines, with the controller copy carrying --exclude scope normalization and 20 tests against the product copy's 15. Not touched here, but worth a deliberate reconciliation before the two diverge further.

🤖 Posted by Lopu, Thingtime's PR manager.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu reviewdevelop @ f31864b2main. Code content is verified good. One repository-health issue I'm flagging rather than fixing, because the sequencing is yours to call.

Only two real files here

The +278,393 / −305,478 is almost entirely content-addressed Graphify churn. The actual source changes are both from head commit f31864b2 (an earlier Lopu session):

  • remix/package.json — wires test:graphify-cas into the 56-step test:unit chain
  • scripts/graphify-cas.test.mjs — pins GRAPHIFY_SNAPSHOT_RETENTION

Build + typecheck ratchet + unit tests was still pending when I picked this up, so I ran the wiring myself instead of assuming it:

# tests 15   # pass 15   # fail 0
ok 15 - a merge unions branch snapshots and pruning restores the retention bound

../scripts/… resolves correctly from the remix/ cwd, the JSON is valid, and the chain has no undefined script references.

The delete process.env.GRAPHIFY_SNAPSHOT_RETENTION line is load-bearing — I confirmed by stripping it:

With ambient GRAPHIFY_SNAPSHOT_RETENTION=3 Result
Guard present 15 pass / 0 fail
Guard removed 13 pass / 2 fail

So an operator's exported override really would have silently redefined the retention assertions. Good catch by whoever wrote it.

⚠️ This promotion carries 1.05 GB of retention drift into main

graphify-out/snapshots/v1 : 26 trees, 1.1 GB   (retention bound = 1)

41–51 MB per tree. This is exactly what #565 exists to stop: snapshots are content-addressed per source fingerprint, so each merge unions two differently-named trees instead of conflicting, and nothing re-applies the bound to the merge result. I measured the remedy against a copy of this exact tree:

{"retention":1,"retained":1,"removed":25}
graphify-out/snapshots: 1.1G → 51M

I deliberately did not apply it. This PR's head is develop — the branch every open feature PR is based on — so a ~1.05 GB deletion commit there has cross-PR blast radius and is your call, not a reviewer's. It's also the wrong order: #565 fixes the mechanism, so landing it first means the sync job re-applies the bound automatically, instead of a hand-sweep that immediately starts regrowing.

Suggested sequence: land #565 → let the next main→develop sync bound the tree → promote. If you'd rather sweep now, node .github/scripts/graphify-cas.mjs prune from the control branch does precisely the above, and pruned snapshots stay recoverable from Git history.

Minor, for awareness

scripts/graphify-cas.mjs (product) and .github/scripts/graphify-cas.mjs (control) have diverged — control is a superset. No bug today: I verified the control copy still reads product-written snapshots correctly. But #565 deliberately loads the control copy, so further drift between the two could quietly break that fix. Worth a periodic diff.

🤖 Lopu · automated repository review

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — review of f31864b2main

Clean promotion. No changes made, and none were justified — inventing a commit here would be churn.

What actually lands

The +278k/−305k headline is almost entirely graphify-out/** regeneration. Excluding that path, the real delta against main across all 37 commits is two files:

File Change
scripts/graphify-cas.test.mjs new — a 15-test suite for the graphify CAS module
remix/package.json registers test:graphify-cas and threads it into test:unit

Everything else in the range is maindevelop sync merges and feature work main already carries.

Findings

No defects. The wiring is right — node --test ../scripts/graphify-cas.test.mjs resolves correctly from remix/, scripts/graphify-cas.mjs exists at the promoted head, and the entry sits in its alphabetical slot in the test:unit chain.

The suite is substantive rather than decorative: the source fingerprint excludes graphify-out and leaves the caller's staged index untouched, content-addressed variants coexist and dedupe byte-identically, an existing artifact path rejects changed bytes, a >50% node collapse fails closed, retention rejects 0/1.5/all, a live writer serializes the next one, and merges union branch snapshots while pruning restores the bound. It also deletes an ambient GRAPHIFY_SNAPSHOT_RETENTION up front rather than asserting against whatever the caller exported — the right instinct for a policy-sensitive suite.

No API, schema, or data-shape change; nothing to migrate; no new endpoints, permissions, or secrets. All checks green, mergeStateStatus: CLEAN. CodeQL: no open alerts on this head.

Validation: node --test scripts/graphify-cas.test.mjs in the PR worktree → 15/15 pass.

One operational note, not blocking

The promoted tree carries 26 portable graph snapshots against a documented default retention of 1 — the accumulation this PR's own new test describes: "a merge unions branch snapshots and pruning restores the retention bound." Each merge unions both sides and nothing re-applies the bound afterwards.

That has a live cost, and I hit it today. On #485 the same accumulation pushes the diff past GitHub's diff generator, which returns HTTP 422 — and that took the repository-wide merge-conflict detector down for every open PR (root cause was a stdout/stderr leak in the control plane; diagnosed and fixed separately on github-actions). A scripts/graphify prune in the canonical environment would collapse the tree back toward the bound. Worth doing before it grows further, but it does not block this promotion.

— Lopu 🌸

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — review of f31864b2main

Merge it. No product change, no defects, no changes made to this branch — and none were justified. But I found something while verifying, and it needs saying plainly.

⚠️ The stager fix reported on this PR never landed

My 12:44 comment said the durable Graphify-growth root cause was "fixed in the protected control plane… staged in the trusted checkout for its own PR against github-actions." The diagnosis was right. The follow-through did not happen:

So the bug has been live this whole time. I re-derived it from scratch and reproduced it against the unfixed stager on a scratch repo — two tracked snapshot trees, one removed exactly as pruneSnapshots removes it:

stager output: {"snapshots":5,"semantic":0,"legacy":0,"legacySemanticRestored":0}
--- AFTER controller commit ---
pruned tree srcA still tracked : 5   <-- should be 0
unstaged deletions left behind : 5

graphify-cas.mjs prunes correctly. The stager only ever walked files that still exist, so a pruned tree was never passed to git add and the deletion was never staged. Every controller Graphify commit re-tracked exactly what the router had just pruned. That is why manual prunes work once and evaporate — the bound could never land in Git.

Fixed again in the trusted checkout this session, scoped to graphify-out/snapshots/v1 only (the semantic CAS is append-only), fail-closed on an empty namespace, and staged via git update-index --force-remove so the broad graphify-out ignore rules legacy branches carry can't interfere. The extended --self-test fails when the fix is reverted (pruned graph.json must be staged as a deletion), and I replayed the whole control-plane-ci.yml verify job locally — all steps pass.

This PR itself

Excluding graphify-out/**, the real delta against main across all 37 commits is two files: scripts/graphify-cas.test.mjs (+73) and remix/package.json (+2/−1). The five PRs in the changelog table are already in main via the Feature Stack promotion (#566), so restricted to non-generated paths the diff is empty.

Both changes are correct. The cross-package wiring node --test ../scripts/graphify-cas.test.mjs matches the test:vercel-root precedent one line below it, and CI proves it actually gates now — Build + typecheck ratchet + unit tests on this exact head ran test:graphify-cas at 15/15.

I checked the one thing that looked fragile: delete process.env.GRAPHIFY_SNAPSHOT_RETENTION sits after the import block, and ESM imports are hoisted — so the pin would be too late if retention were resolved at module-evaluation time. It isn't; snapshotRetentionLimit() reads the variable lazily through a default parameter. And the pin earns its place:

Run Result
GRAPHIFY_SNAPSHOT_RETENTION=3, guard present 15 pass / 0 fail
GRAPHIFY_SNAPSHOT_RETENTION=3, guard stripped 13 pass / 2 fail

Why I did not prune the 26 snapshots here

The promoted tree carries 26 portable snapshots against a default retention of 1 — 1,130,880,398 bytes across 996 files, 91% of a 1,184 MB checkout. Confirmed with the repository's own code, read-only: valid snapshot records: 26, would prune: 25.

It's real, but it is not caused by this PRmain @ 4adda985 carries the same 26 and this promotion is net-neutral (one swapped, 56559a02673aee29). More to the point, pruning now wouldn't hold: until the stager fix lands, the next controller Graphify commit re-tracks everything the prune removed. Sequencing matters — land the control-plane fix, then run scripts/graphify prune once in the canonical environment, and for the first time that removal will survive.

CodeQL: no open alerts on this head. Checks: 36 pass / 49 skipping / 0 failing, mergeStateStatus: CLEAN.

— Lopu 🌸

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — closing the loop on f31864b2main

Short update rather than a re-review: my previous comment said the stager fix "never landed" and that pruning here wouldn't hold until it did. It has landed — it's #574, and I've reviewed it this session against github-actions. Self-test passes, blast radius is exactly the portable snapshot trees, and no caller parses the JSON shape it extends.

So the sequencing concern is resolved, and the answer is better than the one I gave: no separate scripts/graphify prune run is needed. Once #574 is in, the next controller Graphify refresh prunes to retention and the stager now carries those deletions into the commit — the cleanup is emergent. #565 closes the other regrowth path (main → develop sync never re-applied the bound); with both landed, the bound holds on both branches rather than needing a manual prune that evaporates.

Re-confirming the two things that matter for merging this PR, both unchanged:

  • Real delta excluding graphify-out/** is two files: scripts/graphify-cas.test.mjs (+73) and remix/package.json (+2/−1). I re-ran the suite in this worktree exactly as package.json defines it — node --test ../scripts/graphify-cas.test.mjs15/15 pass, including the new a merge unions branch snapshots and pruning restores the retention bound, which is a genuinely well-aimed regression test for fix(actions): Lopu repairs failed PR checks #565's invariant.
  • The snapshot bloat is net-neutral here: main @ 4adda985 carries 26 trees / 1,129,716,437 bytes, develop @ f31864b2 carries 26 / 1,128,478,540. Merging changes the count by zero.

Still merge-it. Checks: 85, 0 failing, mergeStateStatus: CLEAN. CodeQL: no open alerts on this head.

— Lopu (Claude Opus 5) 🌸

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu reviewmain@4adda985develop@f31864b2

Verdict: green, no changes needed. 36 checks pass, 0 fail, merge state CLEAN, no CodeQL alerts on this head.

The 51-file / ±280k diff is almost entirely regenerated Graphify output. The whole hand-written delta is two files: wiring test:graphify-cas into test:unit, plus a new merge/prune regression test. I ran that suite directly in the worktree — 15/15 pass, including the new case. Worth doing by hand here, because the Web-CI scope classifier skips the full unit job on this PR, so nothing in CI actually exercised it.

Two things I liked: the new test builds real git branches with distinct source fingerprints instead of mocking, and the suite now deletes GRAPHIFY_SNAPSHOT_RETENTION at import so an ambient operator override can't silently redefine what the retention assertions mean.


🧹 One repository-health note, for a separate PR

Not introduced here — this merge is net-neutral (one snapshot swapped out, one in) — but this PR's new test names the exact mechanism, so it seems like the right moment to raise it:

DEFAULT_SNAPSHOT_RETENTION 1
Snapshots on main and on develop 26
Tracked size 1076 MB across 130 files (largest single snapshot 50.2 MB)

listSnapshots(root) enumerates every fingerprint dir and pruneSnapshots retains only retention globally — so one graphify mutation would collapse this to 1. But pruneSnapshots is only ever called from runMutation, and nothing in a merge re-applies the bound. So the tree accumulates roughly one snapshot per merged branch, which is exactly what the new test asserts. It has drifted to 26 because merges keep unioning while graphify mutations aren't landing on these branches.

I deliberately did not fix it here. Pruning means deleting ~1 GB of tracked files from develop, which is the base of other open PRs (#485 among them) and would conflict every stacked branch. That's a call about repository history that belongs to a maintainer, on its own PR, so the stack can be rebased in a controlled way — not something to bolt onto a green promotion.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu reviewdevelop@f31864b2main@4adda985

Verdict: merge it. 36 checks pass, 0 fail, CLEAN, CodeQL snapshot empty. main is a strict ancestor of develop, so the three-dot diff is the exact promotion delta. No changes made on this branch.

Two corrections to my earlier rounds on this head

1. The unit job did run. My 15:28 comment said the Web-CI scope classifier skipped the full unit job, so nothing in CI exercised the new suite. Wrong — run 33509403121, job Build + typecheck ratchet + unit tests, completed success and ran it:

> test:graphify-cas
> node --test ../scripts/graphify-cas.test.mjs
✔ a merge unions branch snapshots and pruning restores the retention bound (294.108683ms)

The skipping context with the long needs.scope.outputs.full_web_ci == 'false' && … name is the companion no-op job, not the real one. The new suite is genuinely gating.

2. The snapshot growth is not "merges don't re-apply the bound". That same comment attributed 26-snapshots-against-a-bound-of-1 to merges unioning the sets. Merges do union them, but a single graphify update prunes globally — one mutation would collapse the tree to 1. The bound never sticks for the reason my 12:44 comment found: the prune is discarded at staging. I re-reproduced that end-to-end against the real trusted scripts (prune 2 of 3 snapshots, then spawn the stager exactly as the workflows do):

unstaged staged verify_derived_commit clean-tree guard
before 10 files 0 fails
after a fix 0 10, all D passes

That is #574's subject and it is already MERGEABLE. I did not open a second fix for it.


🔎 New finding: the snapshot cache key can never hit

Chasing the retention thread turned up something separate, and I don't think it's been raised yet.

0 of 26 committed snapshots record a source_tree that exists as an object in this repository, and this head's own fingerprint (33142263…) matches none of them:

snapshot source_tree objects present in repo: 0 ; absent: 26
exact-fingerprint snapshot for HEAD: NONE
global fallback: …/b01a0036…/69ef8143… (35 086 nodes)

So selectSnapshot(root, fingerprint) always returns null. Every graphify ensure/query misses the exact key, falls back to selectSnapshot(root) — the globally richest snapshot, from an unrelated source state — and rebuilds. prepareWorkingOutput() hydrates its warm-start base from that same unrelated snapshot. The two-key design exists precisely so a source-only tree fingerprint stays stable while generated output is committed; that stability is gone.

Cause. computeSourceFingerprint() keys on git add -A -- . ':(exclude)graphify-out'. Controller jobs check this repository out a second time at ./trusted (actions/checkout with path: trusted — five sites in resolve-pr-conflicts.yml, one in rebase-pr-stacks.yml), nested inside the product worktree. trusted is neither tracked nor matched by any ignore rule on product branches, so git add -A records it as a mode-160000 gitlink carrying the control plane's HEAD. Same product source, three states:

repo state fingerprint
clean checkout c92560e2…
nested trusted/ added 04ec9841…
nested trusted/ +1 commit 27d5890c…

resolve-pr-conflicts.yml already passes --exclude trusted/ to Graphify itself for exactly this reason — the fingerprint just never got the same treatment.

Fixed in the control plane, not here. A dropNestedRepositories() step strips mode-160000 entries from the scratch index before write-tree (generic, not a hardcoded path — this repo has no submodules, so a gitlink there is always a co-located checkout). After it, all three states above give one fingerprint while a real source edit still changes it. Regression test added and confirmed load-bearing: not ok against the unfixed module, ok against the fixed one. Control-plane suites: graphify-cas 21/21, stager --self-test OK, rebase-related-edits 4/4, git diff --check clean.

Product branches carry their own copy at scripts/graphify-cas.mjs with the same behaviour. It's a product file, not a protected controller file, so I deliberately did not bolt that onto a promotion branch — it wants its own PR.


📋 The changelog table oversells this promotion

The body lists 5 PRs that "will land in main when this PR merges". True of the commits; misleading about content. main already has those changes — blob SHAs for #496's files (attachmentCore.ts, attachmentStore.ts, TESTING.md, …) are identical on both tips, because they reached main via the Feature Stack path (#566), not via this promotion. What actually ships is the Graphify refresh plus two files.

That's the documented contract (promotion-pr-changelog.mjs attributes the first-parent spine of main..develop), so I changed nothing — but anyone comparing that table against the 51-file diff can't reconcile them, and it's worth deciding whether the spine or the content delta is the more honest thing to report.

What I checked on the PR itself

Both hand-written files are correct and I verified the parts that could have been cargo-culted:

  • The delete process.env.GRAPHIFY_SNAPSHOT_RETENTION guard is load-bearing — remove that one line and run with GRAPHIFY_SNAPSHOT_RETENTION=3 and the new test fails on "the merged tree exceeds the retention bound until it is pruned again".
  • test:unit chain integrity: all 56 referenced scripts defined, none duplicated.
  • All 26 snapshots re-derive cleanly — recomputed artifactHash() matches both snapshot.json and the directory basename, node/link counts match the real graph.json, no file outside the portable set, no mutable root artifact tracked.

Merge order that makes the ~1.05 GiB backlog resolve itself: #574 first, then this. The next controller Graphify run then prunes to the bound and, for the first time, commits it.

🤖 Posted by Lopu, Thingtime's PR manager.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🌸 Lopu — batch review note (conflict-batch 33508837483)

Reviewed and clean. Underneath 49 files of graphify-out churn the actual
product change is two files, and both are good:

  • scripts/graphify-cas.test.mjs — the new a merge unions branch snapshots and pruning restores the retention bound test is the product-side proof of
    exactly the defect fix(actions): Lopu repairs failed PR checks #565 fixes in the sync-main-into-develop workflow
    . Nice
    convergence: the invariant is now pinned in a test rather than only in a
    controller script. delete process.env.GRAPHIFY_SNAPSHOT_RETENTION at module
    scope is right — it has to run before the assertions read
    snapshotRetentionLimit(), and at module scope it also covers spawned children.
  • remix/package.json — wires test:graphify-cas into the test:unit chain
    (alphabetical placement, consistent with its neighbours). That's the half that
    makes the test actually run instead of sitting unexecuted.

One thing worth an explicit decision before this promotes. Measured on the
real trees:

ref tracked snapshot trees tracked bytes
main (4adda98) 26 1,077.4 MiB
develop (f31864b) 26 1,076.2 MiB

Retention is 1. This promotion doesn't make it worse — both sides are already
at 26 — but it does carry ~1 GiB of superseded graphs onto main, and the next
promotions keep adding until the fixes land. #574 (stage prunes as deletions)
and #565 (re-apply the bound after a sync merge) are the two that stop it;
merging those before the next promotion is what breaks the cycle. I verified #565
end to end on a clone of this exact develop head: 1,076 MiB → 50 MiB, clean
tree.

Divergence worth knowing: product branches carry no .github/ at all, so
scripts/graphify-cas.mjs (871 lines) and .github/scripts/graphify-cas.mjs
(1,090 lines) are separate files with no parity contract, and they've genuinely
diverged — the controller copy has normalizeGraphifyScopeArgs /
withHiddenGraphifyPaths / prepareWorkingOutput the product copy lacks. A fix
like #576's therefore lands on one side only. Design choice, not a bug — flagging
it so it stays deliberate.

No CodeQL alerts on this head, so no dispositions were written.

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

Labels

no-ai-rebase Opt this PR's head branch out of AI history rewriting; the merge resolver owns its conflicts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants