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-nonemptyrooted onorigin/main@86e430fb. - 1357-test baseline green (R83 close).
data/colibri.dbanddata/ams.dbnot 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(wastrue).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: ''})throwsZodError(was: succeeded).ThoughtRecordToolInputSchema(re-export) inherits the constraint automatically.- The MCP tool
thought_recordnow 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.dbanddata/ams.dbhas a storedhashcolumn committed to specific content bytes. - Re-running
computeHashon those same bytes (including'') MUST produce the same output, oraudit_verify_chainwould 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:
npm run build— cleannpm run lint— cleannpm test— all tests pass; ≥3 new tests visible (1 schema-rejection, 1 repository-rejection, 1 writeback-gate-cannot-be-gamed);accepts emptytests removed;computeHash handles empty-stringtest still passes (hash function unchanged)- Empty
content: ''insert paths throw at validation time - Donor
computeHashbyte-output forcontent=''unchanged (proven by the preserved hash test)
Contract complete. Proceed to packet.