Skip to content

facts: entity string "null" is resolved instead of treated as absent, so facts land on entity_slug='null' #4755

Description

@jalagrange

Summary

When a caller passes the four-character string "null" as the entity — rather than a JSON null — nothing rejects it. It passes the non-empty check, flows into resolution, fails to match a page, falls back to itself, and the facts are written with entity_slug='null'.

The stub guard then correctly refuses to create the page. The net effect is that facts are filed under an entity that does not and cannot exist.

Observed

On a v0.47.6.0 brain (the code path is unchanged in v0.47.9.0), an LLM extraction pass emitted "null" as the entity for five statements that had no subject — they were gap/absence statements, which is exactly the shape that has nothing to attach to.

Audit line in ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl:

{"ts":"...","slug":"null","source_id":"default","fact_count":5,"reason":"unprefixed"}

Result in Postgres: five facts rows with entity_slug = 'null'. Note fact_count: 5 — a single guard fire can carry several facts, so this is not necessarily one row per incident.

The source page carried no facts fence, so those five rows render on no page at all, and no entity lookup can reach them.

Mechanism (line numbers from v0.47.9.0)

  1. src/core/verbs.ts:160entity: typeof p.entity === 'string' && p.entity.trim() ? p.entity.trim() : null. "null" is a non-empty string, so it passes through unchanged.
  2. src/core/facts/write-single.ts:69const resolved = input.entity ? await resolveEntitySlugWithSource(...) : null. "null" is truthy, so the resolve branch runs. A real JS null would have stopped here.
  3. resolveEntitySlugWithSource finds no page and returns nothing.
  4. src/core/facts/write-single.ts:72resolved?.slug ?? input.entity falls back to the raw input, so the slug becomes the string "null".
  5. src/core/facts/write-single.ts:75resolutionSource is null, so fallbackResolved is true in the fence writer.
  6. src/core/facts/fence-write.ts:357!target.slug.includes('/') is true, so the stub guard fires with reason unprefixed.
  7. src/core/facts/backstop.ts:799 — the legacy DB-only path inserts the rows with entity_slug='null'.

Repro

remember --fact "some statement with no subject" --entity "null"

Then:

select id, entity_slug from facts where entity_slug = 'null';

The same applies to any null-like token. I grepped src/core/facts/ and src/core/entities/ and found no filter for null, undefined, none, or n/a in any casing.

Impact

The guard does its job — no phantom null page is created at the brain root, and the facts are not lost. The problems are downstream of that:

  • Facts are filed under a non-existent entity and are unreachable by entity lookup.
  • If the source page has no facts fence, they appear on no page whatsoever, so nothing surfaces them.
  • The stub_guard_24h doctor warning says "the prefix-expansion in resolveEntitySlug is missing a case". For this input that is misleading — it points the operator at the resolver when the actual defect is missing input validation. The resolver behaved correctly; it was asked to resolve a non-name.

Suggested fix

Treat null-like entity strings as absent at the point the string is first accepted. verbs.ts:160 is the natural place, next to the existing empty-string check — a "null" entity means the same thing the caller meant by omitting it.

Optionally also harden write-single.ts:72, so the ?? input.entity fallback refuses to adopt an unresolvable bare token as a slug. That would catch other callers of writeSingleFact (e.g. google/loops-extract.ts:239) without each needing its own guard.

Possibly related, different bug: #3823 (resolveEntitySlug fallback mangling slash-form slugs) touches the same fallback line from the other direction.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage:bug-verifiedReproduced/verified real by automated triage

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions