Contract: fix-thought-content-nonempty

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


1. Goal

Tighten the ζ Decision Trail content schema from z.string() to z.string().min(1) so that empty-content thought records are rejected at validation time. This closes the writeback-gate gameability window where an executor could satisfy enforceWriteback’s COUNT(*) > 0 check by inserting content: "".

2. Pre-conditions

  • Worktree at feature/fix-thought-content-nonempty rooted on origin/main @ 86e430fb.
  • 1357-test baseline green (R83 close).
  • data/colibri.db and data/ams.db not in scope; we do not touch live runtime DBs.

3. Behavioural contract — public surface

3.1 ThoughtRecordSchema.content (src/domains/trail/schema.ts:85)

Before:

content: z.string(),

After:

content: z.string().min(1, 'thought content must be non-empty'),

Effect:

  • safeParse({...VALID, content: ''}).success === false (was true).
  • safeParse({...VALID, content: 'x'}).success === true (unchanged).
  • Error message contains the literal substring 'non-empty' (used by tests).

3.2 CreateThoughtRecordInputSchema (src/domains/trail/repository.ts:114-119)

Before:

const CreateThoughtRecordInputSchema = z.object({
  type: z.enum(THOUGHT_TYPES),
  task_id: z.string().min(1),
  agent_id: z.string().min(1),
  content: z.string(),
});

After:

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

Effect:

  • createThoughtRecord(db, {...VALID, content: ''}) throws ZodError (was: succeeded).
  • ThoughtRecordToolInputSchema (re-export) inherits the constraint automatically.
  • The MCP tool thought_record now rejects empty content at the input-validation layer (before Phase 0 α middleware reaches the handler).

3.3 TSDoc updates

File: src/domains/trail/schema.ts:71-78

Before:

 * Field invariants:
 *   - id, task_id, agent_id, timestamp — non-empty strings.
 *   - content — string (empty allowed; a zero-content thought is valid
 *     if unusual).
 *   - type — one of THOUGHT_TYPES.
 *   - prev_hash, hash — exactly 64 characters. Format (hex/base64/...) is
 *     NOT enforced at this layer.

After:

 * Field invariants:
 *   - id, task_id, agent_id, timestamp — non-empty strings.
 *   - 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.
 *   - prev_hash, hash — exactly 64 characters. Format (hex/base64/...) is
 *     NOT enforced at this layer.

3.4 SQL migration comment (src/db/migrations/003_thought_records.sql:12)

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

The DDL itself remains content TEXT NOT NULL — SQL still tolerates empty strings; the Zod validator is the gate. This split is intentional: existing donor rows in data/ams.db with content='' (if any) remain readable. We refuse new empty rows, not legacy reads.

4. Hash-stability invariant (the load-bearing fact)

computeHash is NOT modified. Its signature, body, and output bytes are unchanged.

Why this matters:

  • Every existing row in data/colibri.db and data/ams.db has a stored hash column committed to specific content bytes.
  • Re-running computeHash on those same bytes (including '') MUST produce the same output, or audit_verify_chain would flag the chain as tampered.
  • The schema-layer .min(1) blocks NEW empty content at INSERT time. It does NOT participate in the hash function.

Test that proves it: trail-schema.test.ts:332-335 (computeHash handles empty-string content cleanly) is PRESERVED. The hash function still accepts empty strings even though the validator rejects them. This separation is intentional and the test asserts it.

5. Test contract

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

Replaced:

it('accepts empty-string content (zero-content thought is valid shape)', () => {
  expect(ThoughtRecordSchema.safeParse({ ...VALID_RECORD, content: '' }).success).toBe(true);
});

With:

it('rejects empty-string content (gameable-writeback fix)', () => {
  const result = ThoughtRecordSchema.safeParse({ ...VALID_RECORD, content: '' });
  expect(result.success).toBe(false);
  if (!result.success) {
    expect(JSON.stringify(result.error)).toContain('non-empty');
  }
});

Preserved unchanged: computeHash handles empty-string content cleanly at line 332. Hash-function still accepts ''.

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

Replaced:

it('accepts empty-string content (zero-content thought is valid)', () => {
  const rec = createThoughtRecord(db, { ...BASE_INPUT, content: '' });
  expect(rec.content).toBe('');
});

With:

it('rejects empty-string content via Zod (gameable-writeback fix)', () => {
  expect(() =>
    createThoughtRecord(db, { ...BASE_INPUT, content: '' }),
  ).toThrow();
});

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

Added (single new test in the enforceWriteback — direct block):

it('rejects empty content at thought-record-create time (writeback-gate cannot be gamed)', () => {
  const task = createTask(db, { title: 'T' });
  // Attempting to seed the gate via createThoughtRecord with empty content
  // throws BEFORE any row reaches the database, so the writeback gate cannot
  // be satisfied with empty content.
  expect(() =>
    createThoughtRecord(db, {
      type: 'reflection',
      task_id: task.id,
      agent_id: 'attacker',
      content: '',
    }),
  ).toThrow();
  // Sanity: gate still trips because no row was inserted.
  expect(() => enforceWriteback(db, task.id)).toThrow(WritebackRequiredError);
});

6. Documentation contract

Doc Change
docs/contracts/p0-7-2-thought-crud-contract.md row 8 “empty-string content works” → “empty-string content rejected”
docs/packets/p0-7-1-trail-schema-packet.md lines 138 + 271 strike “empty allowed” reference
docs/audits/p0-7-1-trail-schema-audit.md no changes — audit doc states donor spec at the time; we don’t rewrite history
docs/verification/p0-7-1-trail-schema-verification.md no changes — historical run record
docs/agents/writeback-protocol.md add one-line cross-ref to schema-level non-empty rule

7. Acceptance gate

The change is accepted iff:

  1. npm run build — clean
  2. npm run lint — clean
  3. npm test — all tests pass; ≥3 new tests visible (1 schema-rejection, 1 repository-rejection, 1 writeback-gate-cannot-be-gamed); accepts empty tests removed; computeHash handles empty-string test still passes (hash function unchanged)
  4. Empty content: '' insert paths throw at validation time
  5. Donor computeHash byte-output for content='' unchanged (proven by the preserved hash test)

Contract complete. Proceed to packet.


Back to top

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

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