Repository navigation
Six fixes for things that loaded fine and showed nothing - #41
Merged
Merged
Conversation
Four faults with one signature: the page rendered, nothing errored, and the content was empty — so each read as "we have no data on this entity" rather than "this link is wrong". 1. /dashboard/profile/ ignored the param most pages link it with. profile-tab.js read only `?entity=`. Seven links across six pages — ops-garbage-review.js, predictions.html (twice), watchlists.html, power-nodes.html, dossiers.html, ops-quality.html — pass `?id=`, and every one of them landed on "No entity selected." Fixed in the reader rather than at the seven call sites, so links written the same way in future also work. 2. The Leads list sent a legacy id to an entity-keyed page. Every name linked to /dashboard/people/?id=<leads.id>, but that page feeds the value to /api/profilers/:entity_id/*, which is keyed on u_entities. A legacy integer id never matches a uuid, so every name on the page opened a profile with every panel blank. GET /api/leads now returns the mapped `entity_id` — the listing already joins entity_legacy_map for the role badges, so it costs one more correlated subquery — and the name links there when it exists, or to the lead detail page (which is keyed on exactly the id we have) when it does not. 3. Bulk "select page" was dead on two of the four pages that offer it. bulk-bar.js resolved #ads-bulk-header-check once in init(). On leads and accounts that cell is static HTML, so it worked. On investors and companies it is emitted inside the async row render — absent at init(), and replaced wholesale on every non-append load. The checkbox still ticked, so it looked like it had worked. Now bound by delegation like the bar's own buttons. The double-click reset timer is cleared rather than stacked; the old code queued a new 2.5 s timer per click, so an earlier one could fire mid-gesture and cancel the second click. 4. The deep health sweep was publicly callable. The Access allow-list covers /api/health because it is a cheap liveness probe, but `/deep` shares that router: 18 binding probes, three COUNT(*) scans of error_log, and a live fetchPage() with an 8 s budget that walks every fetcher tier — including the metered Browser Rendering tier when the cheaper tiers escalate. Unauthenticated, anyone could drive that in a loop. accessGuard is now registered for both /deep mounts, before the health router so it runs first. Verified against the repo's own Hono: /health and /api/health still answer 200, both deep paths answer 401. The documented operator workflow is a browser session, which carries the cookie the guard reads, so it is unaffected; the ops checklist now says so and points uptime monitors at the cheap probe. The site has no JS test runner, so test/site_deeplinks.test.mjs asserts the first three contracts statically over the files — the approach ci_wrangler_version.test.mjs already takes for the workflow YAML — and access_guard.test.mjs gains the fourth. Each was verified to fail with its fix reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
scoreCompanySize carries weight 0.10 and returns 0 with the reason "company size unknown" when it has no headcount. loadEmployerFacts asked `facts` for `org.headcount`, `org.employees`, `company.employees` and `company.headcount` — four spellings, and nothing in the worker has ever written one of them. So the component was zero for every entity against every persona: a hard ceiling of 0.90 on the score, and a permanent "company size unknown" line in the rationale the dashboard shows to explain why someone matched. The predicate that exists is bare `employees`. It is what the registry declares in entities/profile-predicates.ts and what secEdgar/persist.ts already writes, so this converges on the name in use rather than adding a fifth spelling. The four unused ones are kept in the IN list — harmless, and they resolve if a future writer picks one. Reading it is not enough on its own, so the loop is closed end to end: - `accounts.employees` was the one sizing column the account dual-write dropped, so no account entity carried a headcount fact at all. It now writes `employees`. - backfillAccounts names its columns explicitly and did not name that one, so the bulk path — the one that actually populates the graph — would have kept dropping it while the single-record insert path kept it. Both now carry it. - personaMatchTrigger's RELEVANT_PREDICATES set gains `employees`, or a fresh headcount fact would never re-score the entity it belongs to. Checked and deliberately not changed: loadEntityCoords reads six lat/lng predicates that nothing writes either, but scoreGeo falls back to a country centroid and then to an ISO2 comparison, so that one degrades as designed rather than silently zeroing a component. test/persona_headcount.test.mjs asserts written, read and re-triggered together, because fixing two of the three would look right and still score 0.90. Verified it fails with either half reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
The seven ATS sources — greenhouse, lever, ashby, workable, recruitee, personio, smartrecruiters — each select accounts whose meta_json carries an operator-set key (greenhouse_board, lever_company, ashby_company, workable_account, recruitee_company, personio_company, smartrecruiters_company). Nothing in the worker sets any of them automatically. That is by design — the keys are operator-seeded — but it means that on a deployment where nobody has seeded one, every source returns zero rows and records `0 emitted, ok`: byte-for-byte the same run row as a source that fetched a hundred boards and found nothing new. Each source now returns `seeded_accounts` and `boards_fetched`, counted from the loop rather than hardcoded, so "nothing to scan" and "scanned, nothing new" are different rows. That alone would have changed nothing, because the channel it travels on was never connected. CrawlResult.meta is documented as "per-source counters appended to crawler_runs.meta_json" and the column has existed since migration 161, but runCrawl.ts never read the field and its finalize UPDATE never wrote the column — so any counters a source returned were dropped on the floor. runCrawl now reads and persists them, stringifying defensively so a source that returns something unserialisable cannot be the reason a run records no outcome at all. The crawlers console gains a Notes column that renders those counters; meta_json already reached the client via SELECT *, nothing displayed it. This does not build ATS detection — no source key is now set that was not set before. It makes the absence visible instead of silent, which is the part that was wrong. test/crawler_run_meta.test.mjs asserts all four links: the sources report, the counters are derived, runCrawl persists them, and the console renders them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
getFoundersOf() unions two lookups: a `founder.company_founded` fact
pointing at the company, and career_history rows whose role_title
contains "founder". The fact branch matched on `value_entity_id`, but the
only writer of that predicate — crawler/profileWorkflows/founder.ts —
stores the company as free text in `value_text`, because at extraction
time it has a company name off a bio page and no resolved entity id.
So the fact branch returned nothing, every time. It did not fail loudly:
the union silently reduced to the career_history path, and where that path
had no row with a resolved organization_entity_id the founder checks
reported "no founders on record" rather than "we could not link them".
The lookup now also matches the company's display_name against a trimmed,
lower-cased value_text, with the value_entity_id branch kept first because
that is the correct shape and will match once an extractor resolves the
company. Blank names are excluded explicitly — without that,
TRIM('') = TRIM(' ') joins every blank-named fact to every blank-named
company.
test/diligence_founder_link.test.mjs lifts the SQL out of the source and
runs it against the real migrations, so an equality that cannot match is
caught by executing it rather than by reading it — which is how this got
here. Verified the name case fails against the old query.
Checked and deliberately not changed: `company.privacy_policy_url` and
`company.customer_logo` also have no writer, but those checks return
cautionResult("No privacy policy URL on record.") and
needsHuman("no_customer_logos_recorded"). Those are visible, correct
outcomes for absent data — honest degradation working as designed, not
the silent-zero this commit fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
loadPrimarySectors drives the partition computeInfluence uses for per-sector PageRank and the per-sector power-node flags. It read three predicates from `facts`: entity.primary_sector, firm.sector and company.sector. Nothing in the worker writes any of the three. What is written is the PLURAL `firm.sectors`, emitted by the profile workflows as a JSON array in value_json, and `industry`, emitted by the account dual-write as value_text — both of which also become `sector` tags that the summary rebuild materialises into entity_summary.sectors_csv. So the map came back empty on every sweep, every node fell into one unsectored bucket, and sectors_ranked was 0. Indistinguishable from a graph that genuinely has no sector data, which is why nothing ever surfaced it. entity_summary.sectors_csv is now the primary source: it is the materialised one, already deduped and slugged, one row per entity. The facts lookup stays as a fallback for entities whose summary has not been rebuilt, widened to the predicates that are actually written and reading value_json as well as value_text, since the plural forms are arrays. A value_json that is not an array is ignored rather than throwing. The new failure path logs through logError rather than console.warn — the repo's console gate is added-lines-only, so the file's existing console.warn stays and mine would have failed CI. test/edge_quality_sectors.test.mjs lifts both queries out of the source and runs them against the real migrations, because the entire failure was a query that parsed, ran, and returned nothing. Found by scanning every `predicate = '...'` and `predicate IN (...)` read site in the worker against every `predicate: "..."` write site, then keeping only the sites where NO alternative in the list has a writer. That narrowed 43 orphan predicates to 11 read sites. This was the one that silently disabled a whole computation; the rest are either deliberate multi-spelling tolerance, or absences that already degrade visibly (needs_human, caution, a country-centroid geo fallback). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
The valuation module has four sector lookups, each asking `facts` for `company.sector`, `firm.sector` or `sector`. No writer in the worker produces any of the three. What exists is the plural `firm.sectors` (a JSON array in value_json, from the profile workflows), `industry` (value_text, from the account dual-write), and entity_summary.sectors_csv (the materialised slug list the summary rebuild derives from sector tags). In the comp panel screen a sector miss does not widen the result — it `continue`s past the candidate. So an operator who filtered by sector got an empty panel and the reasonable conclusion that nothing was comparable, rather than any indication that the filter could not match. The private- member discovery query had the same predicates in a JOIN, so it returned nothing for every sector. getCompanySector in impliedValuation returned null for every company, so its sector-matched panel fallback never fired. entities/sector.ts is now the one place that knows the storage shapes: entityHasSector for a per-entity screen, entityPrimarySector for "what is this company's sector", and a literal EXISTS fragment for the query that needs it inline. The edge-quality sweep's copy of the JSON-array helper is gone in favour of the shared one. Two details worth keeping: - sectors_csv is matched with comma-delimited instr, not a substring test. A bare instr matches "fin" inside "fintech" and would widen every filter it was meant to narrow. monitoring/smart.ts already uses this technique against the same column. - The fragment is a literal constant rather than a template. The only variable part would have been the column name, and the repo's SQL gate forbids interpolating into a statement; a caller needing a different column inlines its own copy instead of concatenating one. Deliberately left alone: `company.business_model` in the same screen also has no writer, but unlike sector there is no alternative spelling anywhere in the schema to converge on. Widening it would mean inventing a source. test/sector_resolution.test.mjs lifts the SQL out of the module and runs it against the real migrations, covering each storage shape, the delimiter, superseded facts, and an empty filter argument — which must match nothing rather than everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
runWeeklySnapshotSweep is the only producer for firm_team_snapshots, which
the spinout detector (movements/spinout.ts) reads to notice partners
leaving a firm. Its eligibility query required BOTH:
* a current `firm.team_url` fact — a predicate no writer in the worker
produces, and
* entity_roles.role = 'investor_firm' — assigned only by
deals/investorResolver, while every firm that got its role through the
dual-write carries 'firm'.
Either condition alone was fatal. The sweep returned picked:0, which is
also exactly what "every firm is already up to date" looks like, so
nothing ever surfaced it — and the whole spinout-detection chain behind it
had no input.
firm_people.source_url is the page scraper/pipeline.ts actually parsed a
firm's people from, and the only populated team-page URL in the schema.
The query now unions it in as a second-preference candidate behind the
explicit fact, so an operator override still wins where one exists, and
ROW_NUMBER collapses the two sources to one row per firm. The join casts
firms.id to TEXT to reach entity_legacy_map, matching how
portfolioFromFirmSite.ts and roleInference.ts already bridge that gap.
The role filter is widened to the set the sibling detector uses. Two files
in the same feature reading the same graph through different role sets was
itself part of the bug.
test/team_snapshot_eligibility.test.mjs lifts the query out of the source
and runs it against the real migrations: the scraped-page path, both role
spellings, override precedence, the 7-day window, a non-firm entity, and a
firm with no team page anywhere.
Checked and deliberately not changed: `firm.companies_house_number` also
has no writer, but it is a fallback behind an explicit uk_company_number
and the whole branch is gated on COMPANIES_HOUSE_API_KEY, so it degrades
cleanly.
Correction to an earlier suspicion: admin.ts's backfill-coverage counters
join entity_legacy_map without a CAST, and firms.id/companies.id are
INTEGER against a TEXT legacy_id. I expected that never to match. It does
— SQLite applies numeric affinity to the TEXT operand — verified against
the same engine before touching it. Those counters are correct; no change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
Contributor
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
lead | 418d7a7 | Sep 06 2026, 11:16 PM |
| // Beyond that we surface the error so the caller can decide (retry the | ||
| // job, surface to the operator, etc.) rather than corrupt with a | ||
| // racy write. | ||
| for (let attempt = 0; attempt < 5 && !acquired; attempt++) { |
guillaumelauzier
marked this pull request as ready for review
September 6, 2026 23:32
guillaumelauzier
pushed a commit
that referenced
this pull request
Sep 15, 2026
Commit 418d7a7 added apps/worker/test-dist-q/ — 79 transpiled .js files, 624 KB — and it merged to main via #41. I used `git add -A`, and this .gitignore read exactly `test-dist/`, so a scratch TypeScript out-dir named `test-dist-q` went through a gap one character wide. Nothing generates them: no tsconfig, npm script, shell script or workflow mentions `test-dist-q`, and the only declared out-dirs are `test-dist` (tsconfig.test.json) and `dist` (packages/worker-runner). Nothing imports them either. There is no production impact — wrangler.toml bundles from `main = "src/index.ts"`, the test suite compiles to test-dist/, eslint runs as `eslint 'src/**/*.ts'`, and the CI gates scan ':(glob)apps/worker/src/**/*.ts'. The cost was scanner noise. There is no CodeQL workflow in this repo: the Analyze checks are GitHub default setup, configured in repo settings, and they scan the whole tree with no path exclusions. So a code-quality bot reviewed generated code on #41 and reported a "useless conditional" at test-dist-q/entities/profile.js:49 — the transpiled retry guard `attempt < 5 && !acquired`, which is fine in the source it came from. That would have recurred on every future PR, and the files would have gone on drifting from src/ and polluting every grep of the repo. The ignore rule is now a glob rather than a literal, so the next scratch out-dir cannot repeat this. Verified it still covers plain `test-dist/`, and that `test-dist-q` was the only committed build output in the tree — packages/worker-runner/dist is untracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Platform audit, part five. 1064/1064 tests, typecheck clean, lint 0 errors, all three gates green, Jekyll builds.
Every fix here shares one failure mode: a query that parses, runs, returns nothing, and is indistinguishable from healthy-and-empty. Nothing errors, nothing logs, and the screen just says there is no data.
1. Four dashboard paths that loaded and showed nothing
/dashboard/profile/ignored?id=.profile-tab.jsread only?entity=. Seven links across six pages pass?id=— ops-garbage-review.js, predictions.html (twice), watchlists.html, power-nodes.html, dossiers.html, ops-quality.html — and every one landed on "No entity selected." Fixed in the reader rather than at the seven call sites, so links written the same way in future also work./dashboard/people/?id=<leads.id>, but that page feeds the value to/api/profilers/:entity_id/*, keyed onu_entities. A legacy integer id never matches a uuid, so every name on the page opened a profile with every panel blank.GET /api/leadsnow returns the mappedentity_id— the listing already joinsentity_legacy_mapfor the role badges, so it costs one more correlated subquery — and the name links there, or to the lead detail page when no entity exists yet.bulk-bar.jsresolved#ads-bulk-header-checkonce ininit(). On leads and accounts that cell is static HTML, so it worked. On investors and companies it is emitted inside the async row render — absent atinit(), and replaced wholesale on every non-append load. The checkbox still ticked, so it looked like it had worked. Now bound by delegation, like the bar's own buttons./api/healthbecause it is a cheap liveness probe, but/deepshares that router: 18 binding probes, threeCOUNT(*)scans oferror_log, and a livefetchPage()with an 8 s budget that walks every fetcher tier — including the metered Browser Rendering tier when the cheaper tiers escalate. Unauthenticated, anyone could drive that in a loop. Verified against the repo's own Hono that/healthand/api/healthstill answer 200 and both deep paths answer 401. The documented operator workflow is a browser session, which carries the cookie the guard reads, so it is unaffected; the ops checklist now says so and points uptime monitors at the cheap probe.2. Persona matching could not score above 0.90, on anything, ever
scoreCompanySizecarries weight 0.10 and returns 0 with reason "company size unknown" when it has no headcount.loadEmployerFactsaskedfactsfor four spellings —org.headcount,org.employees,company.employees,company.headcount— and nothing in the worker writes any of them. A hard ceiling of 0.90 on every match, and a permanent false line in the rationale the dashboard shows to explain why someone matched.The predicate that exists is bare
employees: what the registry declares inentities/profile-predicates.tsand whatsecEdgar/persist.tswrites. Closed end to end, because fixing two of three would look right and still score 0.90:accounts.employeeswas the one sizing column it dropped, so no account entity carried a headcount fact at all.backfillAccountsnames its columns explicitly and did not name that one, so the bulk path — the one that actually populates the graph — would have kept dropping it.personaMatchTrigger'sRELEVANT_PREDICATESgains it, or a fresh headcount fact never re-scores its entity.Checked and left alone:
loadEntityCoordsreads six lat/lng predicates nothing writes either, butscoreGeofalls back to a country centroid and then an ISO2 comparison — degrading as designed rather than silently zeroing a component.3. Sector lookups read predicates nothing writes
Found by scanning every
predicate = '...'andpredicate IN (...)read site in the worker against everypredicate: "..."write site, then keeping only the sites where no alternative in the list has a writer. That narrowed 43 orphan predicates to 11 read sites. Two were live defects:loadPrimarySectorsdrives the partitioncomputeInfluenceuses for per-sector PageRank and the per-sector power-node flags. Every node fell into one unsectored bucket andsectors_rankedwas 0 — identical to a graph that genuinely has no sector data, which is why it went unnoticed.continues past the candidate. So an operator who filtered by sector got an empty panel and the reasonable conclusion that nothing was comparable. The private-member discovery query had the same predicates in a JOIN;getCompanySectorreturned null for every company, soimpliedValuation's sector-matched panel fallback never fired.What is actually written: the plural
firm.sectors(a JSON array invalue_json, from the profile workflows),industry(value_text, from the account dual-write), andentity_summary.sectors_csv(the materialised slug list the summary rebuild derives fromsectortags).entities/sector.tsis now the one place that knows all three shapes.sectors_csvis matched with comma-delimitedinstr, not a substring test — a bareinstrmatches "fin" inside "fintech" and would widen every filter it was meant to narrow.monitoring/smart.tsalready uses this technique against the same column.Deliberately left alone:
company.business_modelin the same screen also has no writer, but unlike sector there is no alternative spelling anywhere in the schema to converge on. Widening it would mean inventing a source.4. Founder diligence could not link a founder through facts
getFoundersOfunions afounder.company_foundedfact withcareer_historyrows whose role title contains "founder". The fact branch matched onvalue_entity_id, but the only writer —crawler/profileWorkflows/founder.ts— stores the company as free text invalue_text, because at extraction time it has a name off a bio page and no resolved entity.The branch returned nothing every time. It did not fail loudly: the union silently reduced to the career path, and where that had no row with a resolved
organization_entity_id, the founder checks reported "no founders on record" rather than "we could not link them."Blank names are excluded explicitly — without that,
TRIM('') = TRIM(' ')joins every blank-named fact to every blank-named company.Checked and left alone:
company.privacy_policy_urlandcompany.customer_logoalso have no writer, but those checks returncautionResult("No privacy policy URL on record.")andneedsHuman("no_customer_logos_recorded")— visible, correct outcomes for absent data.5. The weekly firm team snapshot picked zero firms, forever
runWeeklySnapshotSweepis the only producer forfirm_team_snapshots, which the spinout detector reads to notice partners leaving. Its eligibility query required both a currentfirm.team_urlfact (no writer) andentity_roles.role = 'investor_firm'(assigned only bydeals/investorResolver, while every firm that got its role from the dual-write carriesfirm). Either alone was fatal, and the sweep returnedpicked:0— which is also what "every firm is up to date" looks like.firm_people.source_urlis the pagescraper/pipeline.tsactually parsed a firm's people from, and the only populated team-page URL in the schema. It is now a second-preference candidate behind the explicit fact, so an operator override still wins, andROW_NUMBERcollapses the two sources to one row per firm. The role filter is widened to the set the sibling detector (movements/spinout.ts) already uses — two files in the same feature reading the same graph through different role sets was itself part of the bug.6. An idle buyer-signal crawler looked exactly like a working one
The seven ATS sources — greenhouse, lever, ashby, workable, recruitee, personio, smartrecruiters — each select accounts by an operator-set
meta_jsonkey. Nothing sets those keys automatically. That is by design, but it means a deployment where nobody has seeded one records0 emitted, ok: byte-for-byte the same run row as a source that fetched a hundred boards and found nothing new.Each source now returns
seeded_accountsandboards_fetched, counted from the loop rather than hardcoded. That alone would have changed nothing, because the channel was never connected:CrawlResult.metais documented as "per-source counters appended tocrawler_runs.meta_json" and the column has existed since migration 161, butrunCrawl.tsnever read the field and its finalizeUPDATEnever wrote the column. Both fixed, stringifying defensively so an unserialisable value cannot be the reason a run records no outcome at all. The crawlers console gains a Notes column —meta_jsonalready reached the client viaSELECT *, nothing displayed it.This does not build ATS detection — no key is now set that was not set before. It makes the absence visible instead of silent.
Verification
Eight new test files, 48 tests. Each was run with its fix reverted to confirm it fails. The SQL-level ones lift the statement out of the source and execute it against the real migrations, because in every case the bug was a query that parsed, ran, and matched nothing — reading it would not have caught it. The site has no JS test runner, so the three dashboard contracts are asserted statically over the files, the approach
ci_wrangler_version.test.mjsalready takes for the workflow YAML.One correction: I suspected
admin.ts's backfill-coverage counters were broken — they joinentity_legacy_mapwithout aCAST, andfirms.id/companies.idare INTEGER against a TEXTlegacy_id. I expected that never to match. It does; SQLite applies numeric affinity to the TEXT operand. Verified against the same engine before touching anything, so no change was made.Still open, and needs you
Unchanged from #39 and #40:
src/ai/workflows.tsdeclares all 29 Workflow classes as plain classes with arunmethod rather than extendingWorkflowEntrypoint. If that assumption is wrong, every Workflow instance fails on start — which would also mean the profiler batch and the frontier drain silently do nothing. Not checkable without a live dispatch, and I will not rewrite 29 classes on a guess. If you can dispatch one Workflow and say whether it runs, I will act on the answer.Verified real and deliberately not fixed here:
publication_authorsandidentity_postshave no migration and no writer, and both consumers document the absence and degrade honestly; 292 mutating handlers have no Origin/Referer check, which is a design decision rather than a patch.🤖 Generated with Claude Code
https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
Generated by Claude Code