@stage5/lumine 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,715 @@
1
+ # Lumine delegated-administrator contracts
2
+
3
+ `lumine admin` exposes deterministic community-management primitives. It does
4
+ not decide whether content is good, harmful, original, deserving of effort, or
5
+ worth commenting on. An LLM or human operator makes those judgments from the
6
+ canonical structured data.
7
+
8
+ ## Security and run model
9
+
10
+ - The saved Lumine login always authenticates the real operator. The API reloads
11
+ that user's current role from the writer database on every request.
12
+ - Only the server-configured administrator with authoritative administrator
13
+ management authority may delegate. Effective Level 5 alone is rejected.
14
+ Only the immutable
15
+ server-owned Zero and Ciel user IDs are approved. Usernames and CLI flags are
16
+ not authority.
17
+ - `daily-run start` creates or returns a six-hour `delegated-admin` run with
18
+ explicit scopes and one public actor. Every run-scoped CLI command loads the
19
+ canonical active run and sends its ID; the API rejects a missing, expired, or
20
+ mismatched run.
21
+ - The public content actor is Zero or Ciel. Mikey's operator ID is retained in
22
+ private audit rows and is not embedded in public comment metadata.
23
+ - Delegated HTTP work never authenticates as the bot, opens a bot socket, changes
24
+ bot sessions, or updates bot presence/last-seen/typing state. Normal content
25
+ mutations still emit Twinkle's canonical real-time content events.
26
+ - A later human reply to a delegated Zero/Ciel comment enters the existing
27
+ server-side autonomous comment-assistant pipeline. Lumine does not need to
28
+ remain running.
29
+
30
+ `auto` selects Zero first when no completed rotation exists, then alternates
31
+ after a successfully completed run that performed a mutation. Failed,
32
+ abandoned, expired, and read-only runs do not advance rotation. Reusing a run
33
+ key returns the original run and identity. One writer-locked identity-state row
34
+ serializes concurrent starts.
35
+
36
+ Comment mode is stored only on the current run:
37
+
38
+ - `off` (default): no draft or post scope.
39
+ - `draft`: server-generated drafts, no public comment.
40
+ - `post`: drafts plus idempotent publication through the ordinary comment path.
41
+
42
+ ## Common JSON types
43
+
44
+ All `--json` success output is one uncolored JSON value:
45
+
46
+ ```ts
47
+ type Success<D> = {
48
+ ok: true;
49
+ status: "success" | "already_done" | "no_op" | "maximum_reached";
50
+ changed?: boolean; // mutations only
51
+ data: D;
52
+ };
53
+ ```
54
+
55
+ Failures print one JSON value, write no progress prose, and exit nonzero:
56
+
57
+ ```ts
58
+ type Failure = {
59
+ ok: false;
60
+ status:
61
+ | "unauthenticated"
62
+ | "forbidden"
63
+ | "not_found"
64
+ | "validation_error"
65
+ | "partial_failure"
66
+ | "internal_error"
67
+ | "error";
68
+ error: {
69
+ code: string;
70
+ message: string;
71
+ details: unknown | null;
72
+ };
73
+ };
74
+ ```
75
+
76
+ Shared records:
77
+
78
+ ```ts
79
+ type Author = { id: number | null; username: string | null };
80
+
81
+ type Attachment = {
82
+ filePath: string | null;
83
+ fileName: string | null;
84
+ fileSize: number | null;
85
+ thumbUrl: string | null;
86
+ url: string | null; // canonical attachment URL when path and name exist
87
+ };
88
+
89
+ type RecommendationState = {
90
+ recommendedByActor: boolean;
91
+ actorRecommendationId: number | null;
92
+ anyoneCanReward: boolean | null;
93
+ count: number;
94
+ items: Array<{
95
+ id: number;
96
+ actor: Author;
97
+ anyoneCanReward: boolean;
98
+ createdAt: number | null;
99
+ }>;
100
+ };
101
+
102
+ type RewardState = {
103
+ totalTwinkles: number;
104
+ actorTwinkles: number;
105
+ actorAlreadyRewardedThree: boolean;
106
+ caps: {
107
+ maxRewardAmount: number;
108
+ maxRewardAmountForOnePerson: number;
109
+ } | null;
110
+ items: Array<{
111
+ id: number;
112
+ recipientUserId: number | null;
113
+ rewarder: Author;
114
+ type: string | null;
115
+ amount: number;
116
+ comment: string | null;
117
+ claimed: boolean;
118
+ createdAt: number | null;
119
+ }>;
120
+ };
121
+
122
+ type Subject = {
123
+ id: number;
124
+ url: string; // https://www.twin-kle.com/subjects/<id>
125
+ author: Author;
126
+ createdAt: number | null; // Unix seconds
127
+ updatedAt: null;
128
+ title: string | null;
129
+ description: string | null;
130
+ attachment: Attachment | null;
131
+ hasSecretAnswer: boolean;
132
+ hasSecretAttachment: boolean;
133
+ secret: { hasSecret: boolean; revealed: boolean | null };
134
+ effortLevel: number; // 0 means unassigned
135
+ effortRevision: number;
136
+ featured: { member: boolean; order: number | null }; // one-based order
137
+ createdByAuthor: boolean;
138
+ recommendation: RecommendationState;
139
+ reward: RewardState;
140
+ root: { type: string | null; id: number | null };
141
+ ageRestriction: string | null;
142
+ deleted: false;
143
+ unavailable: false;
144
+ };
145
+
146
+ type Comment = {
147
+ id: number;
148
+ url: string; // https://www.twin-kle.com/comments/<id>
149
+ subjectUrl: string | null;
150
+ author: Author;
151
+ createdAt: number | null;
152
+ updatedAt: null;
153
+ content: string | null;
154
+ contentHidden: boolean;
155
+ attachment: Attachment | null;
156
+ parentCommentId: number | null;
157
+ replyToCommentId: number | null;
158
+ isNotification: boolean;
159
+ ageRestriction: string | null;
160
+ deleted: false;
161
+ unavailable: false;
162
+ recommendation: RecommendationState;
163
+ reward: RewardState;
164
+ };
165
+
166
+ type StandalonePost = {
167
+ contentType: "aiStory" | "dailyReflection";
168
+ contentId: number;
169
+ id: number;
170
+ url: string;
171
+ author: Author;
172
+ createdAt: number | null;
173
+ updatedAt: null;
174
+ title: string | null;
175
+ question: string | null;
176
+ content: string | null;
177
+ explanation: string | null;
178
+ imagePath: string | null;
179
+ audioPath: string | null;
180
+ deleted: false;
181
+ unavailable: false;
182
+ recommendation: RecommendationState;
183
+ reward: RewardState;
184
+ };
185
+
186
+ type Pagination = {
187
+ nextCursor: string | null;
188
+ hasMore: boolean;
189
+ exhausted: boolean;
190
+ snapshotMaxId: number;
191
+ };
192
+
193
+ type Identity = {
194
+ key: "zero" | "ciel";
195
+ userId: number;
196
+ username: string;
197
+ };
198
+
199
+ type DailyRun = {
200
+ id: number;
201
+ runKey: string;
202
+ operatorUserId: number;
203
+ publicActorUserId: number;
204
+ identityMode: "auto" | "zero" | "ciel";
205
+ commentMode: "off" | "draft" | "post";
206
+ sessionKind: "delegated-admin";
207
+ scopes: string[];
208
+ status: "active" | "completed" | "failed" | "expired";
209
+ successfulMutationCount: number;
210
+ startedAt: number;
211
+ expiresAt: number;
212
+ completedAt: number | null;
213
+ failedAt: number | null;
214
+ failureReason: string | null;
215
+ identity: Identity;
216
+ };
217
+ ```
218
+
219
+ ## Identity and daily-run commands
220
+
221
+ ```bash
222
+ lumine admin identity list --json
223
+ lumine admin identity status --json
224
+ lumine admin identity use zero --json
225
+ lumine admin identity use ciel --json
226
+ lumine admin identity use auto --json
227
+ ```
228
+
229
+ Schemas:
230
+
231
+ ```ts
232
+ type IdentityList = Success<{
233
+ identities: Identity[];
234
+ preferredIdentity: "auto" | "zero" | "ciel";
235
+ lastCompletedIdentity: "zero" | "ciel" | null;
236
+ }>;
237
+
238
+ type IdentityStatus = Success<{
239
+ preferredIdentity: "auto" | "zero" | "ciel";
240
+ lastCompletedIdentity: "zero" | "ciel" | null;
241
+ activeRun: DailyRun | null;
242
+ }>;
243
+
244
+ type IdentityUse = IdentityStatus;
245
+ ```
246
+
247
+ `identity use` changes only the preference for a future start. It never changes
248
+ an active run or advances rotation.
249
+
250
+ ```bash
251
+ lumine admin daily-run start --identity auto --comment-mode off --json
252
+ lumine admin daily-run start --identity ciel --comment-mode draft \
253
+ --run-key daily:2026-08-06:review --json
254
+ lumine admin daily-run status --json
255
+ lumine admin daily-run complete --json
256
+ lumine admin daily-run fail --reason "operator stopped" --json
257
+ ```
258
+
259
+ Schemas:
260
+
261
+ ```ts
262
+ type DailyRunStart = Success<{ run: DailyRun }>;
263
+ type DailyRunStatus = Success<{
264
+ run: DailyRun | null;
265
+ lastRun: DailyRun | null;
266
+ }>;
267
+ type DailyRunComplete = Success<{
268
+ run: DailyRun;
269
+ rotationAdvanced: boolean;
270
+ }>;
271
+ type DailyRunFail = DailyRunComplete;
272
+ ```
273
+
274
+ `lastRun` makes a lost-response retry of `complete` or `fail` possible after
275
+ the active pointer has been cleared. Other run-scoped commands accept only the
276
+ current unexpired `active` run. Completion rejects while a content mutation or
277
+ audit finalization is pending; `fail` remains available to abandon such a run
278
+ without advancing rotation.
279
+
280
+ The default run key is `daily:YYYY-MM-DD` in Asia/Bangkok. Supply `--run-key`
281
+ for a separate explicit run. `--idempotency-key` may be supplied to any
282
+ mutation when a caller needs the same retry identity across processes. The CLI
283
+ generates a fresh key for every mutation invocation; if a mutation fails, its
284
+ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
285
+
286
+ ## Canonical lists and inspection
287
+
288
+ ```bash
289
+ lumine admin recommendations list --kind recommend --cursor '<cursor>' --json
290
+ lumine admin subjects candidates --after 2026-08-01T00:00:00Z \
291
+ --cursor '<cursor>' --json
292
+ lumine admin subjects candidates --effort unassigned --json
293
+ ```
294
+
295
+ Schemas:
296
+
297
+ ```ts
298
+ type RecommendationQueueList = Success<{
299
+ items: Array<{
300
+ queueId: string;
301
+ feedId: number;
302
+ contentType: "comment" | "aiStory" | "dailyReflection";
303
+ contentId: number;
304
+ url: string | null;
305
+ subjectUrl: string | null;
306
+ author: Author;
307
+ createdAt: number | null;
308
+ title?: string | null;
309
+ question?: string | null;
310
+ content: string | null;
311
+ explanation?: string | null;
312
+ attachment?: Attachment | null;
313
+ imagePath?: string | null;
314
+ audioPath?: string | null;
315
+ subject?: {
316
+ id: number;
317
+ title: string | null;
318
+ effortLevel: number;
319
+ url: string;
320
+ };
321
+ recommendation: RecommendationState;
322
+ reward: RewardState;
323
+ }>;
324
+ pagination: Pagination & { scannedCount: number };
325
+ }>;
326
+
327
+ type SubjectCandidates = Success<{
328
+ subjects: Subject[];
329
+ pagination: Pagination;
330
+ }>;
331
+ ```
332
+
333
+ Both cursors freeze a primary-key high-water mark and traverse descending IDs,
334
+ so concurrent inserts cannot shift or duplicate later pages. A recommendation
335
+ page can be empty while `hasMore` remains true; continue until `exhausted`.
336
+ Subject `--after` is inclusive, and the opaque cursor is bound to its original
337
+ date and effort filters.
338
+
339
+ For a run-scoped command, `--identity zero|ciel` is an assertion against the
340
+ server-selected run identity; it cannot switch actors locally. A mismatch
341
+ fails before the mutation. `--identity auto` accepts the run's canonical
342
+ selection.
343
+
344
+ ### Query and index design
345
+
346
+ Subject and queue traversal are bounded primary-key walks; the queue reads at
347
+ most 500 `noti_feeds` rows per cursor step before applying the existing Earn
348
+ Recommend eligibility predicates. The joins/`NOT EXISTS` checks are necessary
349
+ to preserve the normal recommendation and skip rules, but they run only for
350
+ IDs in that bounded window. Subject-comment traversal uses
351
+ `idx_comments_isDeleted_subject_id`; standalone-post comments use the existing
352
+ `idx_content_comments_root_deleted` index (whose InnoDB entries also carry the
353
+ primary ID). Recommendation identity uses
354
+ `uniq_content_recommendations_active_identity`; `earn_comment_candidates` is
355
+ driven by its primary key. No offset scan or new broad table scan was added.
356
+
357
+ Deployment can verify the required existing index definitions with:
358
+
359
+ ```sql
360
+ SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME
361
+ FROM information_schema.STATISTICS
362
+ WHERE TABLE_SCHEMA = DATABASE()
363
+ AND (
364
+ (TABLE_NAME = 'content_comments'
365
+ AND INDEX_NAME IN (
366
+ 'idx_comments_isDeleted_subject_id',
367
+ 'idx_content_comments_root_deleted'
368
+ ))
369
+ OR (TABLE_NAME = 'content_recommendations'
370
+ AND INDEX_NAME = 'uniq_content_recommendations_active_identity')
371
+ OR (TABLE_NAME = 'earn_comment_candidates' AND INDEX_NAME = 'PRIMARY')
372
+ )
373
+ ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
374
+ ```
375
+
376
+ If a pre-migration environment lacks them, the repository's existing
377
+ `add-build-pinned-comments.sql`, active-recommendation-identity migration, and
378
+ Earn candidate migration are the canonical creation SQL; apply those migrations
379
+ instead of creating runtime checks or duplicate indexes.
380
+
381
+ For schema review, the exact missing-index DDL represented by those migrations
382
+ is:
383
+
384
+ ```sql
385
+ ALTER TABLE content_comments
386
+ ADD INDEX idx_comments_isDeleted_subject_id (isDeleted, subjectId, id);
387
+ ALTER TABLE content_comments
388
+ ADD INDEX idx_content_comments_root_deleted (rootType, rootId, isDeleted);
389
+ ALTER TABLE content_recommendations
390
+ ADD UNIQUE INDEX uniq_content_recommendations_active_identity
391
+ (rootType, rootId, rootTargetType, activeUserId);
392
+ ```
393
+
394
+ Run only the repository migrations after confirming an index is absent; do not
395
+ execute these statements blindly on a deployed database.
396
+
397
+ ```bash
398
+ lumine admin subject get 123 --include-comments --json
399
+ lumine admin subject comments 123 --cursor '<cursor>' --json
400
+ lumine admin comments get 456 --json
401
+ lumine admin post get https://www.twin-kle.com/ai-stories/88 --json
402
+ lumine admin post comments dailyReflection:99 --cursor '<cursor>' --json
403
+ ```
404
+
405
+ Schemas:
406
+
407
+ ```ts
408
+ type SubjectGet = Success<{
409
+ subject: Subject & {
410
+ secret: {
411
+ hasSecretAnswer: boolean;
412
+ hasSecretAttachment: boolean;
413
+ shown: boolean;
414
+ answer: string | null;
415
+ attachment: unknown | null;
416
+ };
417
+ commentsIncluded: boolean;
418
+ comments: Comment[];
419
+ };
420
+ }>;
421
+
422
+ type SubjectComments = Success<{
423
+ subject: { id: number; url: string; title: string | null };
424
+ comments: Comment[];
425
+ pagination: Pagination;
426
+ }>;
427
+
428
+ type CommentGet = Success<{
429
+ comment: Comment;
430
+ subject: {
431
+ id: number;
432
+ url: string;
433
+ title: string | null;
434
+ secretShown: boolean;
435
+ } | null;
436
+ }>;
437
+
438
+ type StandalonePostGet = Success<{ post: StandalonePost }>;
439
+
440
+ type StandalonePostComments = Success<{
441
+ post: {
442
+ contentType: "aiStory" | "dailyReflection";
443
+ contentId: number;
444
+ url: string;
445
+ };
446
+ comments: Comment[];
447
+ pagination: Pagination;
448
+ }>;
449
+ ```
450
+
451
+ Inspection never silently bypasses secret semantics. Until the selected bot is
452
+ the author or has canonically responded/revealed, secret values and comments
453
+ remain unavailable.
454
+
455
+ ## Subject and Featured mutations
456
+
457
+ ```bash
458
+ lumine admin subject reveal 123 --json
459
+ lumine admin subject effort set 123 --level 2 --json
460
+ lumine admin subject creator set-made-by-poster 123 --json
461
+ lumine admin subject feature 123 --json
462
+ lumine admin subject unfeature 123 --json
463
+ lumine admin featured list --json
464
+ lumine admin featured reorder --subject-ids 30,20,10 --json
465
+ ```
466
+
467
+ Schemas:
468
+
469
+ ```ts
470
+ type SubjectReveal = SubjectGet & {
471
+ status: "success" | "already_done";
472
+ changed: boolean;
473
+ data: SubjectGet["data"] & {
474
+ reveal: {
475
+ status: "created" | "already_revealed";
476
+ notificationCommentId: number | null;
477
+ };
478
+ };
479
+ };
480
+
481
+ type SubjectEffortSet = SubjectGet & {
482
+ status: "success" | "already_done";
483
+ changed: boolean;
484
+ };
485
+
486
+ type SubjectCreatorSet = SubjectEffortSet;
487
+
488
+ type FeaturedList = Success<{
489
+ subjects: Subject[];
490
+ count: number;
491
+ maximum: 20;
492
+ }>;
493
+
494
+ type SubjectFeature = FeaturedList & {
495
+ status: "success" | "already_done";
496
+ changed: boolean;
497
+ };
498
+
499
+ type SubjectUnfeature = SubjectFeature;
500
+ type FeaturedReorder = SubjectFeature;
501
+ ```
502
+
503
+ `reveal` publishes the existing hidden “viewed without responding” notification
504
+ as the selected bot, with the ordinary notification and socket side effects.
505
+ Effort assignment rejects an unrevealed secret subject. Creator attribution
506
+ requires an attachment. Every response is reloaded from the writer.
507
+
508
+ Featured reorder is a complete-set replacement: it rejects duplicates,
509
+ unknown/deleted IDs, missing current members, non-subject rows, and more than
510
+ 20 subjects. Permanent pins and editorial ordering policy are deliberately not
511
+ hardcoded.
512
+
513
+ ## Recommendation, Karma approval, and Twinkle rewards
514
+
515
+ ```bash
516
+ lumine admin post recommend 123 --json
517
+ lumine admin post recommend https://www.twin-kle.com/ai-stories/88 --json
518
+ lumine admin post recommend comment:456 --anyone-can-reward \
519
+ --reward-twinkles 3 --idempotency-key review-456-v1 --json
520
+ lumine admin post reward comment:456 --twinkles 3 --json
521
+ ```
522
+
523
+ Numeric targets default to `subject`. Use `subject:<id>`, `comment:<id>`,
524
+ `aiStory:<id>`, `dailyReflection:<id>`, a canonical URL, or the corresponding
525
+ `--type`.
526
+
527
+ ```ts
528
+ type PriorRecommendationApproval = {
529
+ recommendationId: number;
530
+ recipientUserId: number;
531
+ karmaRecipientUserId: number;
532
+ karmaAwarded: number; // 10 for a new canonical approval; 0 on retry
533
+ karmaPoints: number; // writer-confirmed absolute balance after recomputation
534
+ status: "created" | "already_approved";
535
+ reward: Record<string, unknown>; // canonical users_rewards row
536
+ };
537
+
538
+ type Recommend = Success<
539
+ (SubjectGet["data"] | CommentGet["data"] | StandalonePostGet["data"]) & {
540
+ priorRecommendationApprovals: PriorRecommendationApproval[];
541
+ managementBotDeduplication?: {
542
+ alreadyProcessed: true;
543
+ recommendationId: number;
544
+ publicActorUserId: number;
545
+ reason: "already_recommended_by_approved_management_bot";
546
+ };
547
+ }
548
+ >;
549
+
550
+ type MaxRecommend = Success<
551
+ Recommend["data"] & {
552
+ pairing: {
553
+ recommendationId: number;
554
+ anyoneCanReward: true;
555
+ requestedTwinkles: 3;
556
+ rewardStatus:
557
+ | "created"
558
+ | "already_rewarded"
559
+ | "maximum_reached"
560
+ | "insufficient_coins"
561
+ | null;
562
+ alreadyRewarded: boolean;
563
+ rewardCaps: RewardState["caps"] | null;
564
+ retrySafe: true;
565
+ existingManagementReward?: {
566
+ rewardId: number;
567
+ publicActorUserId: number;
568
+ amount: number;
569
+ };
570
+ };
571
+ }
572
+ >;
573
+
574
+ type RewardThree = Success<
575
+ (SubjectGet["data"] | CommentGet["data"] | StandalonePostGet["data"]) & {
576
+ rewardOperation: {
577
+ status: "created" | "already_rewarded" | "maximum_reached" | null;
578
+ amount: number;
579
+ reward: Record<string, unknown> | null;
580
+ caps: RewardState["caps"] | null;
581
+ };
582
+ }
583
+ >;
584
+ ```
585
+
586
+ Zero/Ciel are normalized to effective Level 5 only inside the shared canonical
587
+ recommendation-approval decision. This narrow rule does not grant delegation,
588
+ management authority, or any other permission. A newly activated qualifying
589
+ recommendation
590
+ approves eligible earlier lower-level recommenders through the existing
591
+ `users_rewards` recommendation mechanism and multiplier. It excludes the
592
+ content author's self-recommendation, the approving bot, both management bots,
593
+ Level 5+ users, deleted rows, and existing ineligible rows. The approval then
594
+ runs the same absolute canonical Karma recomputation as `/user/karma`, using
595
+ writer-locked state. A new approval reports the canonical 10-point contribution;
596
+ a retry reports zero newly awarded and the same confirmed absolute balance.
597
+
598
+ Recommendation history is checked across both management bots, so rotation
599
+ does not recommend the same target again. Changing only `anyoneCanReward` does
600
+ not rerun prior-recommender approval. Approval reward rows are locked and
601
+ writer-read, so concurrent or restored attempts cannot insert the same
602
+ approval twice.
603
+
604
+ Both the standalone and combined reward paths also inspect existing 3-Twinkle
605
+ management rewards across Zero and Ciel. A canonical three from either bot is
606
+ reported as already rewarded instead of adding another management reward.
607
+
608
+ The separate 3-Twinkle reward targets the worthwhile canonical post or comment.
609
+ It uses the selected bot and Twinkle's ordinary canonical balance, Level, and
610
+ recipient rules; Mikey is never charged while Zero/Ciel is displayed. Thus the
611
+ bot's canonical balance is charged whenever the normal economy requires
612
+ payment, while the existing Level-based no-charge rule remains unchanged. The
613
+ reward transaction serializes the rewarder and cap-bearing content row, then
614
+ adds only the amount needed for that actor to total exactly three. Existing
615
+ three is `already_done`; a cap is `maximum_reached`.
616
+ If recommendation succeeds but reward fails, the command exits nonzero with
617
+ `partial_failure` and `retrySafe: true`.
618
+
619
+ ## Persona-backed comments
620
+
621
+ ```bash
622
+ lumine admin daily-run start --identity auto --comment-mode draft --json
623
+ lumine admin comment draft 123 --identity auto --json
624
+
625
+ lumine admin daily-run start --identity ciel --comment-mode post \
626
+ --run-key daily:2026-08-06:comments --json
627
+ lumine admin comment draft 123 --identity ciel \
628
+ --idempotency-key comment-123-draft-v1 --json
629
+ lumine admin comment post --draft-id 77 \
630
+ --idempotency-key comment-123-post-v1 --json
631
+ ```
632
+
633
+ ```ts
634
+ type CommentDraft = Success<{
635
+ draft: {
636
+ id: number;
637
+ runId: number;
638
+ subjectId: number;
639
+ subjectUrl: string;
640
+ publicActorUserId: number;
641
+ commentMode: "draft" | "post";
642
+ personaRevision: string; // SHA-256; raw prompt is never returned
643
+ contextRevision: string; // SHA-256 of canonical subject/comments
644
+ decision: "draft" | "skip";
645
+ reason: string | null;
646
+ content: string | null;
647
+ status: "ready" | "published";
648
+ createdAt: number;
649
+ expiresAt: number;
650
+ publishedCommentId: number | null;
651
+ };
652
+ }>;
653
+
654
+ type CommentPost = CommentGet & {
655
+ status: "success" | "already_done";
656
+ changed: boolean;
657
+ data: CommentGet["data"] & {
658
+ draft: {
659
+ id: number;
660
+ status: "published";
661
+ personaRevision: string;
662
+ contextRevision: string;
663
+ };
664
+ published: {
665
+ commentId: number;
666
+ subjectId: number;
667
+ subjectUrl: string;
668
+ commentUrl: string;
669
+ };
670
+ };
671
+ };
672
+ ```
673
+
674
+ The server loads the canonical subject and complete visible comment context,
675
+ then invokes the existing exact Zero/Ciel system prompt through the shared
676
+ response assembler. The raw prompt is never returned or audited. The model—not
677
+ regexes or keyword rules—chooses `draft` or `skip` under the run policy.
678
+
679
+ Draft IDs are bound to operator, run, public bot, subject, comment mode,
680
+ context revision, persona revision, expiry, and idempotency key. Posting locks
681
+ the draft and context in the same transaction as the ordinary comment insert.
682
+ Changed context or persona rejects publication and requires regeneration.
683
+ Retries return the already-published comment instead of duplicating it.
684
+
685
+ ## Audit, sockets, and deployment
686
+
687
+ Every delegated mutation reserves a private audit row before execution and
688
+ records run ID, real operator ID, public bot ID, session kind, action, target,
689
+ idempotency key, before/after state, result, comment mode, and persona revision
690
+ where applicable. It never stores authorization headers, bearer tokens,
691
+ cookies, passwords, or raw system prompts.
692
+
693
+ Retry acquisition and completion are row-locked and fenced by a private
694
+ per-attempt token, so an expired request cannot overwrite a newer retry. A new
695
+ recommendation and its normal recommendation coin charge commit together; the
696
+ canonical prior-recommender approval and the separate 3-Twinkle reward remain
697
+ independently retryable.
698
+
699
+ Public content actions use ordinary Twinkle fan-out:
700
+
701
+ - comments and secret-view notifications use normal comment notifications;
702
+ - comments emit `new_upload` after commit;
703
+ - recommendations emit `new_recommendation`;
704
+ - recommendation-approval and 3-Twinkle rewards emit `new_reward`;
705
+ - effort/creator changes emit `edit_content`;
706
+ - Featured changes emit a canonical `home_outdated` refresh.
707
+
708
+ Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql` before
709
+ deploying the API. It adds only focused daily-run, rotation, draft, and audit
710
+ tables and indexes; there are no runtime schema checks. The local CLI changes
711
+ are not available to users until a separately authorized npm publication.
712
+
713
+ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
714
+ `comments get`, and `recommend` remain accepted, but the singular command forms
715
+ shown above are the canonical interface.