Packet: fix-thought-content-nonempty

Task slug: fix-thought-content-nonempty Audit: docs/audits/fix-thought-content-nonempty-audit.md Contract: docs/contracts/fix-thought-content-nonempty-contract.md


1. Execution order

# Action File Reason
1 Edit Zod constraint + TSDoc src/domains/trail/schema.ts Canonical fix point
2 Edit input schema src/domains/trail/repository.ts Tool-input layer
3 Edit SQL migration comment src/db/migrations/003_thought_records.sql Doc accuracy
4 Invert schema test + add detail src/__tests__/trail-schema.test.ts Replaces “accepts empty”
5 Invert repository test src/__tests__/domains/trail/repository.test.ts Replaces “accepts empty”
6 Add gameability test src/__tests__/domains/tasks/writeback.test.ts New positive coverage
7 Update derived docs (3 files) docs/contracts/p0-7-2…, docs/packets/p0-7-1…, docs/agents/writeback-protocol.md Doc consistency
8 Run gates n/a build && lint && test
9 Verification doc docs/verification/fix-thought-content-nonempty-verification.md Step 5 evidence

2. Detailed plan per file

2.1 src/domains/trail/schema.ts

@@ -71,9 +71,11 @@
  * Field invariants:
  *   - id, task_id, agent_id, timestamp — non-empty strings.
- *   - content — string (empty allowed; a zero-content thought is valid
- *     if unusual).
+ *   - content — non-empty string. Empty content is rejected because the
+ *     β writeback gate (`enforceWriteback`) only checks for the PRESENCE
+ *     of a thought_record, not its quality. Allowing empty content would
+ *     make the gate gameable (review finding #3).
  *   - type — one of THOUGHT_TYPES.
@@ -83,7 +85,7 @@
   id: z.string().min(1),
   type: z.enum(THOUGHT_TYPES),
   task_id: z.string().min(1),
   agent_id: z.string().min(1),
-  content: z.string(),
+  content: z.string().min(1, 'thought content must be non-empty'),
   timestamp: z.string().min(1),

computeHash body remains untouched (lines 170-188). Critical.

2.2 src/domains/trail/repository.ts

@@ -114,7 +114,7 @@
 const CreateThoughtRecordInputSchema = z.object({
   type: z.enum(THOUGHT_TYPES),
   task_id: z.string().min(1),
   agent_id: z.string().min(1),
-  content: z.string(),
+  content: z.string().min(1, 'thought content must be non-empty'),
 });

2.3 src/db/migrations/003_thought_records.sql

-  content     — thought text (empty string allowed)
+  content     — thought text (non-empty; enforced by Zod schema)

2.4 src/__tests__/trail-schema.test.ts

@@ lines 165-169 @@
- it('accepts empty-string content (zero-content thought is valid shape)', () => {
-   expect(ThoughtRecordSchema.safeParse({ ...VALID_RECORD, content: '' }).success).toBe(
-     true,
-   );
- });
+ it('rejects empty-string content (writeback-gate cannot be gamed)', () => {
+   const result = ThoughtRecordSchema.safeParse({ ...VALID_RECORD, content: '' });
+   expect(result.success).toBe(false);
+   if (!result.success) {
+     expect(JSON.stringify(result.error)).toContain('non-empty');
+   }
+ });

Preserve computeHash handles empty-string content cleanly at line 332 unchanged.

2.5 src/__tests__/domains/trail/repository.test.ts

@@ lines 159-162 @@
- it('accepts empty-string content (zero-content thought is valid)', () => {
-   const rec = createThoughtRecord(db, { ...BASE_INPUT, content: '' });
-   expect(rec.content).toBe('');
- });
+ it('rejects empty-string content via Zod (writeback-gate cannot be gamed)', () => {
+   expect(() =>
+     createThoughtRecord(db, { ...BASE_INPUT, content: '' }),
+   ).toThrow();
+ });

2.6 src/__tests__/domains/tasks/writeback.test.ts

Add new test in the enforceWriteback — direct describe block (after line 258):

it('rejects empty content at thought-record-create time (writeback-gate cannot be gamed)', () => {
  const task = createTask(db, { title: 'T' });
  expect(() =>
    createThoughtRecord(db, {
      type: 'reflection',
      task_id: task.id,
      agent_id: 'attacker',
      content: '',
    }),
  ).toThrow();
  expect(() => enforceWriteback(db, task.id)).toThrow(WritebackRequiredError);
});

Adds import { createThoughtRecord } from '../../../domains/trail/repository.js'; to the imports.

2.7 docs/contracts/p0-7-2-thought-crud-contract.md

- | 8 | `createThoughtRecord` empty-string content works | `content: ''` → insert succeeds, returned |
+ | 8 | `createThoughtRecord` Zod rejects empty content | `content: ''` → throws (writeback-gate fix) |

2.8 docs/packets/p0-7-1-trail-schema-packet.md

Lines 138 + 271 — replace residual “empty allowed” wording with non-empty rationale.

2.9 docs/agents/writeback-protocol.md

Add a one-paragraph note in §1 referencing the schema-level non-empty constraint.

3. Test command sequence

cd /e/AMS/.worktrees/claude/fix-thought-content-nonempty
npm run build
npm run lint
npm test

Expected: all three green; the test count rises by ~1 (one new test in writeback.test.ts; the two inverted tests replace existing tests, no count delta there).

4. Risk gates

  • If lint fails on the new test imports, fix the import order (alphabetical) and rerun.
  • If a hidden fixture in another test file uses content: '', the test will fail with a Zod throw. Fix to '…'. Audit reports none beyond the three covered above.
  • If computeHash test for content: '' fails, STOP — it means the hash function was inadvertently changed. The hash function must remain bytewise stable.

5. Commit plan

# SHA target Message
1 n/a audit(fix-thought-content-nonempty): inventory surface (already committed)
2 n/a contract(fix-thought-content-nonempty): behavioral contract (already committed)
3 THIS packet(fix-thought-content-nonempty): execution plan
4 next feat(fix-thought-content-nonempty): tighten content to .min(1) + invert empty tests + close gameable writeback gate
5 last verify(fix-thought-content-nonempty): test evidence

Packet complete. Implement.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.