Skip to content

Commit e9e6395

Browse files
committed
address review (codex): exempt name-form POV character from reveal-gating
resolveCast only compared c.id to keepFullId, but the caller resolves the POV by id OR name (c.id === povCharacterId || c.name === povCharacterId). So a name-form rewrite request against a reveal-gated POV character in an earlier issue lost the narrator's full private canon (surfaced) or returned unknown-character (dropped). Match the same id-OR-name identity in the exemption. Adds a regression test covering the name-form gated POV.
1 parent faaf935 commit e9e6395

2 files changed

Lines changed: 40 additions & 7 deletions

File tree

server/services/pipeline/perspectiveRewrite.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,20 @@ function shapeCastEntry(char) {
115115
// writer-facing GENERATIVE prompt (it grounds regenerated prose in the cast's
116116
// full canon, incl. background/personality), so it must reveal-gate the canon
117117
// exactly like `buildStageContext` (#2178) — a later-reveal character's secret
118-
// must not leak into an earlier issue's rewrite. `keepFullId` (the POV
118+
// must not leak into an earlier issue's rewrite. `keepFullPov` (the POV
119119
// character being rewritten FROM) is exempt: the narrator knows their own
120-
// secrets, so they keep their full record even when gated. Absent
121-
// `issueNumber` = no gate (backward compatible).
122-
async function resolveCast(series, issueNumber, keepFullId = null) {
120+
// secrets, so they keep their full record even when gated. The exemption
121+
// matches the SAME id-OR-name identity the caller resolves the POV with
122+
// (`c.id === keepFullPov || c.name === keepFullPov`) — otherwise a name-form
123+
// POV request on a reveal-gated narrator would surface/drop them and either
124+
// lose their private canon or return `unknown-character`. Absent `issueNumber`
125+
// = no gate (backward compatible).
126+
async function resolveCast(series, issueNumber, keepFullPov = null) {
123127
const canon = series ? await getSeriesCanon(series).catch(() => ({ characters: [] })) : { characters: [] };
124128
const chars = Array.isArray(canon.characters) ? canon.characters : [];
129+
const isPov = (c) => keepFullPov != null && ((c?.id && c.id === keepFullPov) || (c?.name && c.name === keepFullPov));
125130
const gated = Number.isFinite(issueNumber)
126-
? chars.map((c) => (c?.id && c.id === keepFullId
127-
? c
128-
: filterCanonListForIssue([c], 'character', issueNumber)[0]))
131+
? chars.map((c) => (isPov(c) ? c : filterCanonListForIssue([c], 'character', issueNumber)[0]))
129132
.filter(Boolean)
130133
: chars;
131134
return gated.map(shapeCastEntry).filter(Boolean);

server/services/pipeline/perspectiveRewrite.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,36 @@ describe('generatePerspectiveRewrite', () => {
143143
expect(result.status).toBe('unknown-character');
144144
});
145145

146+
it('exempts a reveal-gated POV character from gating even when selected by NAME (#2178)', async () => {
147+
// Ada is reveal-gated to a later issue than this one (#3), so without the
148+
// name-form exemption she'd be surfaced/dropped and a name-form POV request
149+
// would either return unknown-character or lose her full private canon.
150+
canonChars = [
151+
{ id: 'char-ada', name: 'Ada', role: 'protagonist', personality: 'driven', physicalDescription: 'tall', revealIssue: 8, surfaceDescriptor: 'a quiet stranger' },
152+
{ id: 'char-bly', name: 'Bly', role: 'rival', revealIssue: 8 },
153+
];
154+
seedIssue();
155+
analysisQueue.push({ oneLine: 'tense' });
156+
157+
const result = await svc.generatePerspectiveRewrite('iss-1', { povCharacterId: 'Ada' });
158+
159+
expect(result.status).toBe('complete');
160+
expect(result.rewrite.povCharacterName).toBe('Ada');
161+
// The POV narrator keeps her full private canon: the rewrite prompt's
162+
// povCharacter descriptor still carries her personality (which a surfaced
163+
// view would strip). Without the name-form exemption this request would
164+
// have returned unknown-character or lost this private context.
165+
const povCtx = llmCalls.find((c) => c.stage === 'pipeline-pov-rewrite').ctx.povCharacter;
166+
expect(povCtx.name).toBe('Ada');
167+
expect(povCtx.descriptor).toContain('Personality: driven');
168+
// The prompt's rendered cast roster (gated to this issue) keeps the full POV
169+
// narrator but reduces Bly (gated, no surfaceDescriptor, not the POV) — she is
170+
// dropped, so her name doesn't appear in the drafting roster.
171+
const roster = llmCalls.find((c) => c.stage === 'pipeline-pov-rewrite').ctx.series.characters;
172+
expect(roster.map((c) => c.name)).toContain('Ada');
173+
expect(roster.map((c) => c.name)).not.toContain('Bly');
174+
});
175+
146176
it('caps stored rewrites and keeps newest first', async () => {
147177
seedIssue();
148178
for (let i = 0; i < 14; i++) {

0 commit comments

Comments
 (0)