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
computeHashtest forcontent: ''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.