Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 160 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,68 @@ paths:
'500':
$ref: '#/components/responses/InternalServerError'

/specs/{id}/paragraphs/{nodeId}/acknowledgement:
patch:
operationId: acknowledgeParagraph
summary: Acknowledge or un-acknowledge a note or textBox object node
description: >
Per-node acknowledgement (#545, ADR-079 follow-on): the specifier
affirms they have read and accepted a `note` or a `textBox` `object`
node. `acknowledged: true` clears the issuance-readiness gate's
`specifier_note_present` / `body_object_present` finding for it
WITHOUT removing or hiding the content — it still renders exactly as
before in every output format. `acknowledged: false` reverses it.
Deliberately separate state from `vanish`: only `note` nodes and
`textBox`-kind `object` nodes are acknowledgeable — a `table`-kind
object (structural content, ADR-072) or any other node type is
rejected 422. The toggle is idempotent — re-sending the same value is
a no-op that returns the node unchanged without minting a new content
version. The node must belong to the spec in the path (else 403). The
composed edit gate (ADR-018) rejects an archived spec or one locked
upstream in a DMS (409).
tags: [specs]
parameters:
- $ref: '#/components/parameters/SpecId'
- $ref: '#/components/parameters/NodeId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [acknowledged]
properties:
acknowledged:
type: boolean
description: true to acknowledge, false to un-acknowledge.
actorLabel:
$ref: '#/components/schemas/ActorLabel'
responses:
'200':
description: Updated paragraph node (with its subtree)
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SuccessResponse'
- type: object
required: [data]
properties:
data:
$ref: '#/components/schemas/SpecNode'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/WriteConflict'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'500':
$ref: '#/components/responses/InternalServerError'

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/specs/{id}/paragraphs/{nodeId}/reject:
patch:
operationId: rejectParagraph
Expand Down Expand Up @@ -989,6 +1051,75 @@ paths:
'500':
$ref: '#/components/responses/InternalServerError'

/specs/{id}/paragraphs/{nodeId}/comments/{index}/closure:
patch:
operationId: setCommentClosed
summary: Close or reopen a source-document review comment
description: >
A mutable comment-closure toggle (#545, ADR-079 follow-on) — the only
supported path to clear the issuance-readiness gate's `open_comment`
finding on an existing spec. `closed: true` closes the comment at
`index` (captured in `source_facts.comments`); `false` reopens it.
The node must belong to the spec (else 403). `no-comment` (no comment
exists at `index`) is a lookup miss → 404, not a validation failure.
The toggle is idempotent — re-sending the same value is a no-op that
returns the node unchanged without minting a new content version.
Passes the composed edit gate (ADR-018): archived/upstream-locked →
409. `POST .../accept-as-note` also closes the originating comment as
part of accepting it (#545) — this endpoint is for closing (or
reopening) a comment independently of accepting it as a note.
tags: [specs]
parameters:
- $ref: '#/components/parameters/SpecId'
- $ref: '#/components/parameters/NodeId'
- name: index
in: path
required: true
schema:
type: integer
minimum: 0
description: Zero-based index into the anchor's source_facts.comments.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [closed]
properties:
closed:
type: boolean
description: true to close, false to reopen.
actorLabel:
$ref: '#/components/schemas/ActorLabel'
responses:
'200':
description: Updated paragraph node (with its subtree)
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SuccessResponse'
- type: object
required: [data]
properties:
data:
$ref: '#/components/schemas/SpecNode'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Paragraph not found, or no comment exists at that index
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
$ref: '#/components/responses/WriteConflict'
'500':
$ref: '#/components/responses/InternalServerError'

/specs/{id}/paragraphs/{nodeId}/associations:
get:
operationId: listAssociations
Expand Down Expand Up @@ -8069,6 +8200,15 @@ components:
vanish:
type: boolean
description: Hidden (w:vanish) paragraph — editorial note, not owner-facing
acknowledged:
type: boolean
description: >-
A specifier has read and accepted this `note` or `textBox`
`object` node (#545, ADR-079 follow-on), clearing the
issuance-readiness gate's `specifier_note_present` /
`body_object_present` finding for it WITHOUT removing or
hiding the content — it still renders exactly as before.
Deliberately separate from `vanish`. Present only when true.
pageBreakBefore:
type: boolean
description: >-
Expand Down Expand Up @@ -8643,7 +8783,26 @@ components:
]
op:
type: string
enum: [edit, insert, remove, restore, merge, accept-note, restructure]
description: >
Mirrors the `paragraph_versions.op` enum exactly (migrations
046/055). `acknowledge`/`unacknowledge` and
`close-comment`/`reopen-comment` are the ADR-079 follow-on
readiness-clearance ops (#545) — each writes a history row, so a
real history response can carry them.
enum:
[
edit,
insert,
remove,
restore,
merge,
accept-note,
restructure,
acknowledge,
unacknowledge,
close-comment,
reopen-comment,
]
contentVersion:
type: [integer, 'null']
minimum: 1
Expand Down
7 changes: 7 additions & 0 deletions src/api/contract.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ const RESPONSE_COVERED = new Set([
'patch /specs/{}/paragraphs/{}/removal',
'patch /specs/{}/paragraphs/{}/reject',
'get /revisions/{}',
// #545's two clearance ops return a SpecNode for the same reason and were
// allowlisted on that basis while this branch was open. #649 removed the
// reason, so they are response-verified (and INV-6 exact-match-verified)
// instead — in readiness-clearance.integration.test.ts, next to the
// behavioural assertions, rather than in the block below.
'patch /specs/{}/paragraphs/{}/acknowledgement',
'patch /specs/{}/paragraphs/{}/comments/{}/closure',
]);

// Documented JSON ops not yet response-verified (burned down in PR2…N).
Expand Down
67 changes: 66 additions & 1 deletion src/api/editability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import {
PatchEditabilityBodySchema,
ReclassifyBodySchema,
AcceptNoteBodySchema,
PatchCommentClosureBodySchema,
} from '../ast/index.js';
import type { ConventionRules, Editability } from '../ast/index.js';
import {
setSpecEditabilityOverride,
clearSpecEditabilityOverride,
reclassifySpec,
acceptCommentAsNote,
setParagraphCommentClosed,
ConventionValidationError,
} from '../db/index.js';
import type { OwnershipResult, AcceptNoteOutcome } from '../db/index.js';
import type { OwnershipResult, AcceptNoteOutcome, SetCommentClosedResult } from '../db/index.js';
import { gateErrorResponse } from './edit-gate-response.js';
import { logger } from '../lib/logger.js';

Expand Down Expand Up @@ -192,3 +194,66 @@ export async function acceptAsNoteHandler(req: Request, res: Response): Promise<
res.status(500).json({ success: false, error: 'internal server error' });
}
}

// ── comment closure ────────────────────────────────────────────────────────

function sendCommentClosedResult(res: Response, result: SetCommentClosedResult): void {
switch (result.status) {
case 'not-found':
res.status(404).json({ success: false, error: 'paragraph not found' });
return;
case 'wrong-spec':
res.status(403).json({ success: false, error: 'paragraph does not belong to this spec' });
return;
case 'no-comment':
// A lookup miss ("nothing exists at this index to toggle"), not a
// validation failure — mirrors `not-found` rather than acceptAsNoteHandler's
// 422 for "you tried to create something with no material".
res.status(404).json({ success: false, error: 'no comment at that index' });
return;
case 'updated':
res.status(200).json({ success: true, data: result.node });
return;
}
}

/**
* PATCH /specs/:id/paragraphs/:nodeId/comments/:index/closure — a mutable
* comment-closure toggle (#545, ADR-079 follow-on): the only supported path
* to clear `open_comment` on an existing spec. `{ closed: true }` closes the
* comment at `index`; `false` reopens it. Idempotent — a no-op returns the
* node unchanged without bumping any version. Passes the composed edit gate
* (ADR-018): archived/upstream-locked → 409.
*/
export async function closeCommentHandler(req: Request, res: Response): Promise<void> {
const ids = parseIds(req, res);
if (!ids) return;
const index = INDEX_SCHEMA.safeParse(req.params['index']);
if (!index.success) {
res.status(400).json({ success: false, error: 'invalid comment index' });
return;
}
const body = PatchCommentClosureBodySchema.safeParse(req.body);
if (!body.success) {
res.status(400).json({ success: false, error: 'closed must be a boolean' });
return;
}
try {
const result = await setParagraphCommentClosed(
ids.specId,
ids.nodeId,
index.data,
body.data.closed,
body.data.actorLabel
);
sendCommentClosedResult(res, result);
} catch (err) {
const gate = gateErrorResponse(err);
if (gate) {
res.status(gate.status).json(gate.body);
return;
}
logger.error({ err }, 'close comment failed');
res.status(500).json({ success: false, error: 'internal server error' });
}
}
70 changes: 70 additions & 0 deletions src/api/paragraph-acknowledgement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Request, Response } from 'express';
import { z } from 'zod';
import { PatchAcknowledgementBodySchema } from '../ast/index.js';
import { setParagraphAcknowledged } from '../db/index.js';
import { gateErrorResponse } from './edit-gate-response.js';
import { logger } from '../lib/logger.js';

/**
* PATCH /specs/:id/paragraphs/:nodeId/acknowledgement — per-node
* acknowledgement (#545, ADR-079 follow-on). `{ acknowledged: true }` clears
* the readiness gate's `specifier_note_present` / `body_object_present`
* finding for a `note` or `textBox` `object` node WITHOUT removing or hiding
* the content — it still renders exactly as before. Only `note` nodes and
* `textBox`-kind `object` nodes are acknowledgeable; every other node type
* (including a `table`-kind object, ADR-072) is rejected 422. The toggle is
* idempotent — a no-op returns the node unchanged without bumping any
* version. Passes the composed edit gate (ADR-018): archived/upstream-locked
* → 409. Mirrors removeParagraphHandler's structure exactly.
*/
export async function acknowledgeParagraphHandler(req: Request, res: Response): Promise<void> {
const specId = z.uuid().safeParse(req.params['id']);
if (!specId.success) {
res.status(400).json({ success: false, error: 'invalid spec id' });
return;
}
const nodeId = z.uuid().safeParse(req.params['nodeId']);
if (!nodeId.success) {
res.status(400).json({ success: false, error: 'invalid node id' });
return;
}
const body = PatchAcknowledgementBodySchema.safeParse(req.body);
if (!body.success) {
res.status(400).json({ success: false, error: 'acknowledged must be a boolean' });
return;
}

try {
const result = await setParagraphAcknowledged(
specId.data,
nodeId.data,
body.data.acknowledged,
body.data.actorLabel
);
switch (result.status) {
case 'not-found':
res.status(404).json({ success: false, error: 'paragraph not found' });
return;
case 'wrong-spec':
res.status(403).json({ success: false, error: 'paragraph does not belong to this spec' });
return;
case 'not-acknowledgeable':
res.status(422).json({
success: false,
error: `node type "${result.nodeType}" cannot be acknowledged — only note nodes and textBox objects are`,
});
return;
case 'updated':
res.status(200).json({ success: true, data: result.node });
return;
}
} catch (err) {
const gate = gateErrorResponse(err);
if (gate) {
res.status(gate.status).json(gate.body);
return;
}
logger.error({ err }, 'acknowledge paragraph failed');
res.status(500).json({ success: false, error: 'internal server error' });
}
}
28 changes: 28 additions & 0 deletions src/api/paragraph-clearance-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Router as RouterType } from 'express';
import { acknowledgeParagraphHandler } from './paragraph-acknowledgement.js';
import {
patchEditabilityHandler,
reclassifyHandler,
acceptAsNoteHandler,
closeCommentHandler,
} from './editability.js';

/**
* Wires the editability/comment-resolution and readiness-finding-clearing
* routes onto the shared router instance — extracted out of router.ts to
* keep it under the enforced ESLint `max-lines: 400` (project override,
* CLAUDE.md), mirroring `registerCheckpointRoutes`'s identical extraction.
* The last two routes are new in #545 (ADR-079 follow-on) — acknowledgement
* and comment closure, the two remaining supported paths to clear a
* readiness finding. Registers directly on `router` (never a mounted
* sub-router) so `expressRouteManifest`
* (src/test-utils/contract/validate-response.js), which only walks one level
* of `router.stack`, still sees every route.
*/
export function registerParagraphClearanceRoutes(router: RouterType): void {
router.patch('/specs/:id/paragraphs/:nodeId/editability', patchEditabilityHandler);
router.post('/specs/:id/reclassify', reclassifyHandler);
router.post('/specs/:id/paragraphs/:nodeId/comments/:index/accept-as-note', acceptAsNoteHandler);
router.patch('/specs/:id/paragraphs/:nodeId/acknowledgement', acknowledgeParagraphHandler);
router.patch('/specs/:id/paragraphs/:nodeId/comments/:index/closure', closeCommentHandler);
}
Loading
Loading