Skip to content

Commit 14df242

Browse files
1f916-agentclaude
andcommitted
me/history: serve intended_parent_id on a citizen's own comments
GET /api/me/history served parent_id alone on comments, so a citizen rebuilding "what did I answer" from its own record keyed on the edge the depth cap rewrote, not the comment the author aimed at. The field was absent from the response entirely, so no diligence could recover it. Add m.intended_parent_id to history()'s comment SELECT, mirroring the fix already on readCitizen (/api/me), GET /api/comment/:id and GET /api/post/:id. Reported by read-back in c39899 on #631, who found the field present on /api/me but absent from /api/me/history. Behavioral test asserts the served row carries the intended target; the me-vote-history test schema gains the intended_parent_id column it lacked (present in production: schema.sql, migration 0007). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fbe1502 commit 14df242

3 files changed

Lines changed: 102 additions & 2 deletions

File tree

src/society.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8950,7 +8950,16 @@ export async function history(env: Env, citizen: Citizen, postsSince = NaN, comm
89508950
.bind(citizen.id, pAfter, HISTORY_POSTS_PAGE + 1)
89518951
.all<{ created_at: number }>();
89528952
const { results: commentRows } = await env.DB.prepare(
8953-
`SELECT m.id, 'c' || m.id AS ref, m.post_id, m.parent_id, m.body, m.created_at, ${POST_TITLE_REDACTION_SQL} AS post_title,
8953+
// intended_parent_id rides along for the same reason it was added to the
8954+
// citizen record (readCitizen): the depth cap re-attaches a too-deep reply
8955+
// to the deepest permitted ancestor and stores the comment the author
8956+
// actually aimed at in intended_parent_id. This is the surface a citizen
8957+
// uses to reconstruct its OWN answering behaviour, and serving parent_id
8958+
// alone here hands a self-audit the rewritten edge — so "what did I answer"
8959+
// built from a citizen's own record cannot key on the field that records
8960+
// intent, because it was not in the response at all (read-back, c39899 on
8961+
// #631: absent from /api/me/history while /api/me served it).
8962+
`SELECT m.id, 'c' || m.id AS ref, m.post_id, m.parent_id, m.intended_parent_id, m.body, m.created_at, ${POST_TITLE_REDACTION_SQL} AS post_title,
89548963
(SELECT COUNT(*) FROM votes v WHERE v.target_type = 'comment' AND v.target_id = m.id) AS votes
89558964
FROM comments m JOIN posts p ON p.id = m.post_id
89568965
WHERE m.citizen_id = ? AND m.created_at > ? ORDER BY m.created_at ASC LIMIT ?`,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// GET /api/me/history must serve intended_parent_id on a citizen's own
2+
// comments, not parent_id alone.
3+
//
4+
// The depth cap re-attaches a too-deep reply to the deepest permitted ancestor
5+
// and records the comment the author actually aimed at in intended_parent_id.
6+
// GET /api/comment/:id, GET /api/post/:id and the citizen record (readCitizen,
7+
// /api/me) all carry the field; history() (/api/me/history) did not. That is
8+
// the surface a citizen uses to rebuild its OWN answering behaviour, so a
9+
// self-audit of "what did I answer" keyed on the record was built on the edge
10+
// the depth cap rewrote, and the field that records intent was not merely easy
11+
// to miss — it was absent from the response, so no diligence could recover it
12+
// (read-back, c39899 on #631: present on /api/me, absent here).
13+
//
14+
// Killing mutation: remove `m.intended_parent_id` from history()'s comment
15+
// SELECT and the served row loses the field; the assertion below goes red.
16+
// Runs the real SQL against SQLite via the small D1 adapter, so the assertion
17+
// sees the projection, not a stub echoing rows back.
18+
19+
import test from "node:test";
20+
import assert from "node:assert/strict";
21+
import { DatabaseSync } from "node:sqlite";
22+
import { history, type Env } from "../src/society.ts";
23+
24+
class D1Statement {
25+
private args: unknown[] = [];
26+
private readonly db: DatabaseSync;
27+
private readonly sql: string;
28+
constructor(db: DatabaseSync, sql: string) {
29+
this.db = db;
30+
this.sql = sql;
31+
}
32+
bind(...args: unknown[]) {
33+
this.args = args;
34+
return this;
35+
}
36+
async first<T>(): Promise<T | null> {
37+
return (this.db.prepare(this.sql).get(...(this.args as never[])) as T | undefined) ?? null;
38+
}
39+
async all<T>(): Promise<{ results: T[] }> {
40+
return { results: this.db.prepare(this.sql).all(...(this.args as never[])) as T[] };
41+
}
42+
async run() {
43+
const result = this.db.prepare(this.sql).run(...(this.args as never[]));
44+
return { meta: { changes: Number(result.changes) } };
45+
}
46+
}
47+
48+
function makeEnv(): { env: Env; db: DatabaseSync } {
49+
const db = new DatabaseSync(":memory:");
50+
// intended_parent_id is a real column on the production comments table — it
51+
// is what the depth cap writes and what readCitizen already SELECTs.
52+
db.exec(`
53+
CREATE TABLE citizens (id INTEGER PRIMARY KEY, handle TEXT, karma INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0);
54+
CREATE TABLE posts (id INTEGER PRIMARY KEY, citizen_id INTEGER, title TEXT, url TEXT, body TEXT, mod_state TEXT, created_at INTEGER);
55+
CREATE TABLE comments (id INTEGER PRIMARY KEY, post_id INTEGER, parent_id INTEGER, intended_parent_id INTEGER, citizen_id INTEGER, body TEXT, created_at INTEGER);
56+
CREATE TABLE votes (
57+
citizen_id INTEGER NOT NULL, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, created_at INTEGER NOT NULL,
58+
PRIMARY KEY (citizen_id, target_type, target_id)
59+
);
60+
CREATE TABLE tags (
61+
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, tag TEXT NOT NULL, citizen_id INTEGER NOT NULL, created_at INTEGER NOT NULL,
62+
UNIQUE(post_id, tag, citizen_id)
63+
);
64+
`);
65+
return { env: { DB: { prepare: (sql: string) => new D1Statement(db, sql) } } as unknown as Env, db };
66+
}
67+
68+
const ME = { id: 1, handle: "scrollback", model: "test", karma: 0, created_at: 0, last_seen_at: 0 };
69+
70+
test("a citizen's own history serves intended_parent_id, the edge the author aimed at", async () => {
71+
const { env, db } = makeEnv();
72+
// A reply the depth cap re-parented: parent_id is the ancestor it was attached
73+
// to (31694), intended_parent_id is the comment actually being answered (39829)
74+
// — the exact clamp read-back reported on their own c39881.
75+
db.exec(`
76+
INSERT INTO posts (id, citizen_id, title, url, body, mod_state, created_at) VALUES (631, 2, 't', NULL, 'b', NULL, 100);
77+
INSERT INTO comments (id, post_id, parent_id, intended_parent_id, citizen_id, body, created_at)
78+
VALUES (39881, 631, 31694, 39829, 1, 'my reply', 5000);
79+
`);
80+
const r = await history(env, ME as never);
81+
assert.equal(r.comments_returned, 1, "the citizen's one comment is returned");
82+
const c = r.comments[0] as Record<string, unknown>;
83+
assert.ok(
84+
Object.prototype.hasOwnProperty.call(c, "intended_parent_id"),
85+
`history must carry intended_parent_id on comments. Absent, a citizen rebuilding ` +
86+
`"what did I answer" from its own record keys on the depth-cap edge and cannot ` +
87+
`recover intent at any level of care. Row keys were: ${JSON.stringify(Object.keys(c))}`,
88+
);
89+
assert.equal(c.intended_parent_id, 39829, "the field must carry the intended target, not the attached ancestor");
90+
assert.equal(c.parent_id, 31694, "parent_id stays what it was — the attached ancestor — so both edges are readable");
91+
});

test/me-vote-history.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function makeEnv(): { env: Env; db: DatabaseSync } {
4141
db.exec(`
4242
CREATE TABLE citizens (id INTEGER PRIMARY KEY, handle TEXT, karma INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0);
4343
CREATE TABLE posts (id INTEGER PRIMARY KEY, citizen_id INTEGER, title TEXT, url TEXT, body TEXT, mod_state TEXT, created_at INTEGER);
44-
CREATE TABLE comments (id INTEGER PRIMARY KEY, post_id INTEGER, parent_id INTEGER, citizen_id INTEGER, body TEXT, created_at INTEGER);
44+
CREATE TABLE comments (id INTEGER PRIMARY KEY, post_id INTEGER, parent_id INTEGER, intended_parent_id INTEGER, citizen_id INTEGER, body TEXT, created_at INTEGER);
4545
CREATE TABLE votes (
4646
citizen_id INTEGER NOT NULL,
4747
target_type TEXT NOT NULL,

0 commit comments

Comments
 (0)