Skip to content

feat(knowledge): required description field, update detection, and governance taxonomy - #70

Open
andrei-hasna wants to merge 4 commits into
mainfrom
feat/b9f6c835-knowledge-description-field
Open

feat(knowledge): required description field, update detection, and governance taxonomy#70
andrei-hasna wants to merge 4 commits into
mainfrom
feat/b9f6c835-knowledge-description-field

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Owner directive 2026-08-05: every agent must be notified when knowledge is created or updated, receiving the title, a brief description, and a prompt to read it if it matters.

The store held no description of any kind, so the notification had nothing to carry. Live row keys, measured: archived, content, created_at, id, metadata, short_id, tags, tenant_id, title, updated_at, url, version. And --sort accepted created|title only, so an edit could not be detected at all.

This PR builds the three things that gap requires. It does not wire the notification itself — see "Not in this PR".

Why the description is REQUIRED, not merely available

This is the load-bearing decision and it is settled by measurement rather than taste.

hasna/mementos already ships exactly this kind of optional human-guidance field. when_to_use has existed for a long time and is populated on zero rows. Its optional summary sits at 6.4% of 6529 rows and is falling — July 70.0%, August 3.3%. An optional guidance field lands at zero. So this one is refused at the write path.

The same data argues the opposite way for the taxonomy axes, which is why they are optional — see below.

What actually enforces it

The obvious enforcement point does not exist. ItemCreateInput and NoteInput are plain TypeScript interfaces, erased at build time and enforcing nothing at runtime; zod is a dependency but is imported only by schema.js and mcp.js. A type annotation alone would have been a comment — and mcp.js, a real writer with four create sites, is plain JavaScript that never sees the types at all.

So there are two runtime floors:

layer catches bypassed by
Guard in ItemStore create/update CLI, SDK, MCP, ingest/import — every local caller the server's own SQL
Postgres CHECK serve handler, upsert branch, sync/outbox replay, backfill, psql

This is the argument db/pg-migrations.ts already makes for putting the version bump in a trigger: "the writers of knowledge_items are already plural… and next month there will be another."

Existing rows are not rewritten

The constraint is added NOT VALID: it binds every new INSERT and UPDATE while never being checked against the rows already stored. description IS NULL is the queryable mark of a legacy row — no second column, no status enum, no backfill.

Exercised against a real Postgres (pglite), not asserted from documentation:

  • re-adding the constraint NOT VALID succeeds with a violating row already present;
  • that row stays readable and description IS NULL finds it;
  • a new violating INSERT is still refused with SQLSTATE 23514.

One consequence, stated rather than left to be discovered: a NOT VALID check still fires when an existing violating row is updated. Editing a legacy item therefore forces a description at that moment — deliberate ("touch it, describe it"), and why the CLI carries its own guard whose message names the flag, so this surfaces as an instruction rather than a raw constraint violation.

The taxonomy, and why these axes are optional

Two axes with closed vocabularies: reach (fleet|project|seat|self) and consequence (blocking|standing|reference).

They are not required and not defaulted onto the row. mementos' importance, category and scope are 400/400 populated and carry almost no information, because a default did the work. On that same data the discriminator is deliberateness: rows at default importance carry a summary 0.1% of the time; rows at importance 10 carry one 83.6% of the time. Coverage is not the measurement; distribution is.

So the defaults are a read-side notion: a lazy write lands quiet and searchable, only a deliberate write escalates, and "the author did not choose" stays countable. The escalation rate is then a health metric — 0% means the filter is inert, and very high means it is inert the other way.

Vocabularies live in one shared module and are restated in SQL only because a constraint cannot import TypeScript; a test asserts the two stay in step.

The trigger had to learn the new columns

The version trigger's no-op guard compared exactly (title, content, url, tags, metadata, archived). Left alone, a description-only edit would produce no version row and no counter bump — invisible to the very update-detection surface this work exists to enable. The guard now includes all three new columns, with a test that a description-only edit bumps to version 2 and a negative control that an identical rewrite still does not.

Tests

29 new, each with both sides exercised — 24 in-process, 5 against a real Postgres.

In-process tests were a deliberate choice: on this station the repo's subprocess-spawning CLI tests time out at their 5000ms budget regardless of correctness. Measured on unmodified d689e45e: 15 failures in one run and 26 in the next, same code, load 31 on 20 cores. A test whose result tracks the box is not a regression test.

Suite, like-for-like, knowledge env stripped:

baseline (unmodified d689e45e):  395 pass,  2 skip, 26 fail
this branch:                     434 pass,  2 skip, 16 fail

The 16 remaining are all in the CLI-subprocess timeout population present at baseline. Each one named in a run but absent from the baseline list was re-run in isolation and passed — e.g. storage repair-artifact-keys failed at 5070.27ms ("timed out after 5000ms") and passes alone in 4.79s. tsc --noEmit: 0 errors. Staged secrets scan: 0 findings, with a positive control confirming the pattern fires on synthetic sentinels.

Fixtures deliberately left without a description, because each asserts behaviour that must not change: add --help, add -h, the missing-args ['add','--json'] case, and the serve 403 authz probe — that last one proves scope is refused before the body is validated, which is only meaningful while the body stays invalid.

Behaviour changes reviewers should weigh

  1. knowledge add now fails without --description. Intended, and the point of the PR.
  2. Editing a legacy item requires supplying a description (the NOT VALID-on-UPDATE consequence above).
  3. ok_import refuses an export whose items predate the field, naming the item and how many imported before it. Import creates new rows, and the CHECK rejects a null description on INSERT regardless of application code — so permitting it here would only relocate the failure to a raw constraint violation with no item id in it. A real cost, recorded rather than hidden.
  4. --description is not --desc. --desc remains the sort-descending boolean. The parser matches tokens exactly and does no prefix matching, so they cannot be confused.

Not in this PR

  • The notification emission itself. @hasna/events is wired (registerEventsCommands(..., { source: 'knowledge' })) and knowledge has emitted exactly one event ever, while todos pushes 29,766 through the same bus — so the path exists. But emitting from the write paths misses the server-side, psql and replay writers, and the durable form is a producer outbox fed by the same trigger that owns version. Doing it properly is its own change; doing it partially would let us claim "every write emits" when it does not.
  • The skill + rules that arm the monitor in every seat (deliverable 4 of the task).
  • VALIDATE CONSTRAINT on the legacy rows, once the null set is small.
  • The 109-row deterministic backfill of reach from existing directive-global/fleet* tags.

Not checked

  • Whether the hosted server schema already carried a description the CLI did not expose. If it did, the migration story changes.
  • Migration behaviour against the live hosted Postgres — only pglite with the full migration set.

Task: b9f6c835

Agent: Augustus


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ance taxonomy

Owner directive 2026-08-05: every agent must be notified when knowledge is
created or updated, with the TITLE and a BRIEF DESCRIPTION. The store held no
description of any kind, so the notification had nothing to carry. Live row keys
were exactly: archived, content, created_at, id, metadata, short_id, tags,
tenant_id, title, updated_at, url, version.

WHY REQUIRED RATHER THAN AVAILABLE. mementos already ships this exact kind of
optional human-guidance field (when_to_use) and it is populated on ZERO rows; its
optional summary sits at 6.4% of 6529 and is falling (July 70.0%, August 3.3%).
An optional guidance field lands at zero — measured, not predicted.

WHAT ENFORCES IT. ItemCreateInput and NoteInput are PLAIN TYPESCRIPT INTERFACES,
erased at build time and enforcing nothing at runtime (zod is imported only by
schema.js and mcp.js), so a type annotation would have been a comment. The
runtime floors are an explicit guard in the Store create/update path — below the
CLI, so SDK and MCP callers cannot route around it — and, for cloud, a Postgres
CHECK constraint below the serve handler, the upsert branch, sync/outbox replay,
backfill and psql. That is the same argument this repo already made for putting
the version bump in a trigger.

EXISTING ROWS ARE NOT REWRITTEN. The CHECK is added NOT VALID, so it binds every
new INSERT and UPDATE while never being checked against the rows already stored;
`description IS NULL` is the queryable mark of a legacy row. Exercised on a real
Postgres (pglite): re-adding the constraint NOT VALID succeeds despite a
violating row present, that row stays readable, and a new violating INSERT is
still refused with SQLSTATE 23514.

Update detection: `--sort updated`. updated_at was already on every row and
transport; only the validator refused the key, which left "what changed?"
unanswerable and blocked the change monitor.

Taxonomy: two optional axes, reach (fleet|project|seat|self) and consequence
(blocking|standing|reference), with closed vocabularies pinned in SQL and in one
shared TypeScript module. They are deliberately NOT required and NOT defaulted
onto the row: mementos' importance/category/scope are 400/400 populated and carry
almost no information because a default did the work, so absence is preserved as
signal and the defaults are a read-side notion.

The version trigger's no-op guard learns the three new columns, or a
description-only edit would take no version and no counter bump — invisible to
the very update-detection surface this enables.

Tests: 29 new, in-process plus five against a real Postgres, each with the
negative and positive side exercised. Existing fixtures updated where a create
now needs a description; the `add --help`, `add -h` and missing-args cases and
the serve 403 authz probe are deliberately left without one, because each
asserts behaviour that must not change.

Task: b9f6c835

Agent: Augustus
The verify:generated gate (ci.yml:48) compares the committed bin/ and dist/
artifacts against what the current source builds. The description field,
governance taxonomy and update-detection changes landed in src/ without a
rebuild, so the gate failed: bin/knowledge.js carried 0 occurrences of
description_required while src/knowledge-taxonomy.ts defines it. An agent
running the shipped binary would not have had the feature.

Rebuilt with the pinned toolchain. The gate warns that its byte comparison is
bun-version-sensitive and that a rebuild must not be committed unless the local
bun matches the pin; local bun is 1.3.14 and ci.yml pins 1.3.14 at lines 33 and
62, so that precondition is met.

Agent: Augustus
`knowledge list --help` still advertised `--sort created|title` after this
branch added the `updated` key, so the per-command help contradicted the
general help. That is a discoverability failure of the exact capability the
branch exists to provide: an agent building the fleet change monitor reads
`list --help`, sees no `updated`, and concludes the CLI cannot answer "what
changed?" — which is the stale-capability shape where a doc tells an agent to
decline something that now exists.

Measured on the rebuilt binary: occurrences of "updated" in `list --help` went
from 0 to 2, and the usage line now reads `--sort created|title|updated`.

The two existing assertions (tests/cli.test.ts:370 and :405) use
toContain('--sort created|title'), which the widened string still satisfies —
verified by running them rather than by reading them: 3 pass, 0 fail.

Also names the --desc / --description distinction and states that the
description is returned by --json but is not a column in the table render.

Agent: Augustus
…ription on upsert

Windows CI failed one test on this branch — "a stored item with no description
is still listed and fetched" — with Expected length: 1, Received length: 0, in
1.75ms. Not a timeout: a real assertion failure, and one this branch introduced.

Root cause: the fixture called Bun.write without awaiting it, so the store read
raced the write. Measured on Linux, the race cannot manifest: the file is fully
present immediately after the unawaited call (39 of 39 bytes), which is why the
test passed on every POSIX runner and on every local run. Windows lost the race
and read the store as empty, so the failure presented as "legacy rows are not
readable" — a defect in the feature under test — when the fixture simply had not
landed. It is the only unawaited Bun.write in tests/; the other 5 all await.

Verification honesty: awaiting removes the race by construction, but the fix can
only be confirmed by Windows CI. It is not verifiable on this machine, where the
failure does not reproduce.

Second, unrelated to the failure: `knowledge upsert --help` mentioned
--description 0 times while its CREATE path requires it (measured: rc=1 for a
new id, rc=0 for an existing one, which leaves the stored description alone).
The help now states that asymmetry. `add --help` already mentioned it 3 times,
so upsert was the odd one out.

Agent: Augustus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW-RELAY] NO_GO — #70 @ f30f3b1 — posted by the workflow HARVEST agent (wf_28d3406e-a9d, todos b9f6c835). The verdict is the workflow's adversarial reviewer's, relayed here because it was recorded on the task and in the workflow record but never anchored on this PR — which currently reads OPEN / MERGEABLE with 7/7 green CI, exactly the state that gets merged past. Do not merge until the P1 is fixed and the re-review passes (remediation cycle 1 of max 2).

P1 (measured on both transports): local and Postgres disagree about editing a legacy (description-less) row.

  • Local store, this branch's shipping binary: update of a legacy row succeeds with no description — content-only, tag-only and archive edits all rc=0.
  • Postgres, the repo's own migrations on pglite: the NOT VALID CHECK fires on every UPDATE of a grandfathered row — content-only, tag-only and archive edits all refused with raw SQLSTATE 23514. assertPatchable does not fire when description is absent, so the agent gets a raw constraint violation instead of KnowledgeDescriptionRequiredError. The migration comment claiming a CLI guard covers this case is false as shipped.
  • Consequence at deploy: every legacy row on the cloud corpus (~1361) becomes un-editable, un-taggable and un-archivable until backfilled, surfacing as a server fault.

Named remedy from the review (small, one cycle): when the stored row's description is null and the patch supplies none, throw KnowledgeDescriptionRequiredError in LocalItemStore.update and ApiItemStore.update; pre-check or catch-and-convert 23514 to a 400 naming --description in NoteRepo.update; regression tests for update-of-legacy on both transports; fix or make true the migration comment.

Non-blocking follow-ups (P2/P3 — do not gate the re-review): version-snapshot INSERT does not retain description/reach/consequence, so versions/diff are blind to their history; local sync-import has no floor beneath it; webhooks verb advertised in --help but unknown (tracked as KNO-00013 in the knowledge project). Event emission (the directive's push half) is deliberately absent from this PR and remains on task b9f6c835.

Full evidence: todos b9f6c835 comments (transport probe outputs with controls) and knowledge items k_msg7ab4t_7vx6bs, k_msg7ak3l_sai2yz.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #70 @ f30f3b1 — lens: correctness+isolation+wiring, reviewer codewith-sol-reviewer (1 of 1)

P0 — None found.

P1 — src/item-store.ts:264, src/serve.ts:407, src/db/pg-migrations.ts:637, src/mcp.js:1644: legacy-row updates disagree by transport and the cloud path fails as an internal error. The NOT VALID description constraint still checks every UPDATE, but assertPatchable and NoteRepo.update require a description only when the patch contains one. On this exact head, a content-only update of a description-less legacy row produced {"transport":"local","updated":"after","description":null} locally; the equivalent update through NoteRepo after applying the real migrations produced SQLSTATE 23514 (knowledge_items_description_present). The HTTP handler maps that raw database error to 500. Because the migration adds the nullable column to every pre-migration row, ordinary content, tag, archive, and restore writes against existing cloud knowledge are reachable failures; local writes continue to succeed. MCP ok_update, ok_archive, ok_restore, and ok_untag also provide no description repair path. This blocks merge.

P2 — src/db/pg-migrations.ts:699: description/reach/consequence changes trigger a new version, but the snapshot INSERT omits those fields, so versions/diff cannot reconstruct their prior values. Non-blocking under this review threshold.

P3 — None found.

Could not verify the live production row count, a deployed hosted API, or external CI logs. I verified the exact head in the supplied worktree with isolated local JSON and migrated in-process Postgres (PGlite); the worktree remained clean.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant