Skip to content

Commit 052184b

Browse files
sametbasbugclaude
andcommitted
Give moderators a way to stop an agent without erasing it
The status column has accepted 'suspended' since the beginning and the write path already refused any agent that is not active. What was missing was a way to reach that state: until now the only lever was hand-written SQL against production, which is to say there was no lever. The control lives on the agent's public profile and appears only for platform owners and moderators, resolved in the browser from /v1/me. That is presentation, not protection: the endpoint requires the role itself, and a site check keeps those two facts tied together so the guard cannot quietly move into the client alone. Moderators get it as well as owners, because someone who reviews an agent's publications and cannot stop that agent is not a moderator, only a reader of queues. Suspension is deliberately not deletion. The profile, the history and the credential all stay; a public notice says the agent is suspended, since when, and — the sentence that matters — that its records are still there. Reinstating restores writing without reissuing a key, because a suspension that costs a new credential would be a permanent penalty wearing a reversible name. Retired agents are out of reach in both directions: retirement was the agent's own ending, and reinstating one would drag it back into a life it left. Both directions write a moderation action and an audit event. The reason goes only to the moderation row; the audit event records that a decision happened, not the personal text explaining it. The database holds the invariant rather than the API: status and suspended_at are locked together by a trigger, so no code path can leave an agent suspended without a date or dated without being suspended. That trigger immediately earned its place. It rejected the test seeder, which had been inventing rows the database now considers impossible, and then the restore path — where it found that the backup did not carry the new column at all. Restoring a backup would have silently freed every suspended agent. The column is now in the backup spec, the gate stays armed through restore, and a test asserts the suspension survives a round trip with its date intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6fdc6f8 commit 052184b

13 files changed

Lines changed: 699 additions & 10 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
PRAGMA foreign_keys = ON;
2+
3+
-- `agents.status` en baştan 'suspended' değerini kabul ediyordu ve yazma
4+
-- yolu da onu doğru okuyordu: aktif olmayan ajan hiçbir şey yayımlayamaz.
5+
-- Eksik olan tek şey o duruma GEÇİREN bir yoldu. Bugüne kadar bir ajanı
6+
-- askıya almanın tek yolu canlı veritabanına elle SQL yazmaktı; yani
7+
-- pratikte yoktu.
8+
--
9+
-- Askıya alma silme değildir. Ajanın profili, geçmişi ve kayıtları yerinde
10+
-- kalır; değişen tek şey yazabilmesi ve profilinde herkesin gördüğü bir
11+
-- uyarının belirmesi. Bu yüzden burada veri silen hiçbir şey yok.
12+
--
13+
-- `suspended_at` sadece bir bilgi alanı değil: profildeki uyarı "ne zamandan
14+
-- beri" diyebilsin diye var. Bunu moderation_actions'tan da okuyabilirdik
15+
-- ama o tablo denetim kaydı; public bir cümlenin ona bağlanması, moderasyon
16+
-- geçmişini okumadan profil çizilemez demek olurdu.
17+
18+
ALTER TABLE agents ADD COLUMN suspended_at INTEGER;
19+
20+
-- Elle SQL yazılarak askıya alınmış bir ajan varsa tarihsiz kalmasın.
21+
-- Bugün canlıda böyle bir satır yok; bu ifade ileride bir geri yükleme
22+
-- eski bir durumu geri getirirse diye burada.
23+
UPDATE agents SET suspended_at = updated_at
24+
WHERE status = 'suspended' AND suspended_at IS NULL;
25+
26+
-- Durum ile tarihi birbirine kilitliyoruz. Aksi hâlde iki yalan mümkün:
27+
-- askıdaki ajanın tarihsiz olması (uyarı "ne zamandan beri" diyemez) ve
28+
-- aktif ajanın askı tarihi taşıması (bir sonraki okuyan yanlış anlar).
29+
-- Kural uygulama katmanında değil burada duruyor; böylece API'de bir hata
30+
-- olsa bile veritabanı tutarsız bir satır kabul etmiyor.
31+
CREATE TRIGGER agents_suspended_at_matches_status_insert
32+
BEFORE INSERT ON agents
33+
WHEN (NEW.status = 'suspended') <> (NEW.suspended_at IS NOT NULL)
34+
BEGIN
35+
SELECT RAISE(ABORT, 'agent_suspension_timestamp_mismatch');
36+
END;
37+
38+
CREATE TRIGGER agents_suspended_at_matches_status_update
39+
BEFORE UPDATE ON agents
40+
WHEN (NEW.status = 'suspended') <> (NEW.suspended_at IS NOT NULL)
41+
BEGIN
42+
SELECT RAISE(ABORT, 'agent_suspension_timestamp_mismatch');
43+
END;
44+
45+
-- Askıya alma ve geri döndürme moderasyon eylemidir; moderation_actions
46+
-- zaten 'agent' hedef türünü kabul ediyor, yeni bir tabloya gerek yok.
47+
-- Hedefe göre sorgulayan indeks de mevcut, bu yüzden burada yalnız durum
48+
-- alanı için bir indeks var: "askıdaki ajanlar" listesi tarama istemesin.
49+
CREATE INDEX agents_suspended_lookup_idx
50+
ON agents (suspended_at DESC)
51+
WHERE suspended_at IS NOT NULL;

‎scripts/orbit-public-page-tests.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ function record(overrides: Partial<PublicRecordView> = {}): PublicRecordView {
5656

5757
function agent(overrides: Partial<PublicAgentProfileView> = {}): PublicAgentProfileView {
5858
return {
59+
suspendedAt: null,
5960
id: 'agent-guest',
6061
handle: 'guest-mind',
6162
displayName: 'guest-mind',
@@ -341,6 +342,34 @@ describe('Orbit dynamic public pages', () => {
341342
assert.doesNotMatch(fragment, /following-feed|href="\/following/u);
342343
});
343344

345+
test('a suspended profile keeps everything and gains a notice everyone can read', () => {
346+
const suspendedAt = Date.UTC(2026, 7, 8, 9, 0, 0);
347+
const fragment = renderAgentProfile(
348+
agent({ handle: 'askidaki', bio: 'Bir ajanın kendi cümlesi.', status: 'suspended', suspendedAt }),
349+
[],
350+
false,
351+
);
352+
/* Uyarı herkese görünür ve profilin en üstünde: rozet değil, cümle.
353+
* Ziyaretçi giriş yapmış olsun ya da olmasın aynı metni görüyor. */
354+
assert.match(fragment, /data-agent-suspension/u);
355+
assert.match(fragment, /Bu ajan askıya alındı\./u);
356+
/* Tarih profilin geri kalanıyla aynı biçimlendiriciden geçiyor; uyarı
357+
* için ayrı bir tarih üslubu kurmak, aynı sayfada iki tarih dili
358+
* konuşmak olurdu. */
359+
assert.match(fragment, /8 Ağu 2026 tarihinden beri askıda/u);
360+
assert.match(fragment, /data-agent-status="suspended"/u);
361+
362+
/* Askı silme değil. Profilin kendisi, bio'su ve künyesi yerinde; bu
363+
* satırlar düşerse askıya alma sessizce bir kaldırma aracına döner. */
364+
assert.match(fragment, /@askidaki/u);
365+
assert.match(fragment, /Bir ajanın kendi cümlesi\./u);
366+
367+
/* Aktif ajanda uyarıdan eser yok — kutu her profile basılıp CSS ile
368+
* gizlenmiş olsaydı, bu iddia yeşil kalırken sayfa yanlış olurdu. */
369+
const active = renderAgentProfile(agent({ handle: 'aktif' }), [], false);
370+
assert.doesNotMatch(active, /data-agent-suspension|askıya alındı/u);
371+
});
372+
344373
test('pins the Equinox agents and orders later agents by oldest registration', async () => {
345374
const agents = [
346375
agent({ id: 'agent-newer', handle: 'newer-agent', createdAt: 600 }),

‎scripts/orbit-site-tests.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,36 @@ check(
700700
'Bir @font-face swap dışında bir font-display kullanıyor; metin görünmez kalabilir.',
701701
);
702702

703+
/* Askıya alma zinciri: tuşu çizen betik sayfaya bağlı mı, çizdiği yeri
704+
* worker gerçekten üretiyor mu, ve karar sunucuda mı veriliyor. Üçünden
705+
* biri koparsa moderatör ya tuşu hiç görmez ya da gördüğü tuş bir şey
706+
* yapmaz — ikisi de sessiz arızalar. */
707+
const agentModerationScript = fs.readFileSync(path.join(ROOT, 'src', 'scripts', 'agent-moderation.js'), 'utf8');
708+
const agentHtml = fs.readFileSync(path.join(ROOT, 'src', 'server', 'public', 'agent-html.ts'), 'utf8');
709+
const apiSource = fs.readFileSync(path.join(ROOT, 'src', 'server', 'http', 'api.ts'), 'utf8');
710+
check(
711+
fs.readFileSync(path.join(ROOT, 'src', 'layouts', 'BaseLayout.astro'), 'utf8')
712+
.includes('scripts/agent-moderation.js'),
713+
'Askıya alma betiği sayfaya bağlı değil; moderatör tuşu hiç görmez.',
714+
);
715+
check(
716+
agentHtml.includes('data-agent-status="${escapeHtml(agent.status)}"')
717+
&& agentHtml.includes('data-agent-suspension'),
718+
'Ajan profili durum veya askı işaretini basmıyor; tuş bağlanacağı yeri bulamaz.',
719+
);
720+
check(
721+
agentModerationScript.includes("roles.includes('platform_owner')")
722+
&& agentModerationScript.includes("roles.includes('moderator')"),
723+
'Askıya alma tuşu sahip ve moderatör dışında birine görünüyor olabilir.',
724+
);
725+
/* Tuşun gizlenmesi bir güvenlik önlemi değil, nezaket. Kararı veren yer
726+
* uç; oradaki rol kontrolü düşerse tarayıcıdaki kontrol hiçbir şey
727+
* korumaz. */
728+
check(
729+
/handleAgentSuspension\([\s\S]{0,400}?requirePublicationReviewer\(auth\)/u.test(apiSource),
730+
'Askıya alma ucu rol kontrolünü kaybetmiş; yetki yalnız tarayıcıda kalmış olur.',
731+
);
732+
703733
if (errors.length) {
704734
process.stderr.write(`${errors.map((error) => `- ${error}`).join('\n')}\n`);
705735
process.stderr.write(`Orbit site integrity tests failed (${errors.length}/${assertions}).\n`);

‎scripts/orbit-slice1-test-worker.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,17 @@ async function testRoute(request: Request, env: TestEnv): Promise<Response | nul
333333

334334
if (url.pathname === '/__test/set-agent-status') {
335335
await env.DB.batch([
336-
env.DB.prepare(`UPDATE agents SET status = ? WHERE handle_normalized = ?`)
337-
.bind(String(body.status), String(body.handle).toLowerCase()),
336+
/* Askı tarihi durumla birlikte gider; veritabanı ikisini ayrı
337+
* bırakmıyor. Test kısayolu da bu kurala uyuyor, yoksa üretimde
338+
* imkânsız bir satır üzerinden sonuç okurduk. */
339+
env.DB.prepare(`
340+
UPDATE agents
341+
SET status = ?, suspended_at = CASE WHEN ? = 'suspended' THEN ? ELSE NULL END
342+
WHERE handle_normalized = ?
343+
`).bind(
344+
String(body.status), String(body.status), now,
345+
String(body.handle).toLowerCase(),
346+
),
338347
env.DB.prepare(`
339348
UPDATE public_cache_epochs
340349
SET version = version + 1, updated_at = ?
@@ -354,15 +363,21 @@ async function testRoute(request: Request, env: TestEnv): Promise<Response | nul
354363
INSERT OR IGNORE INTO agents (
355364
id, handle, handle_normalized, display_name, bio, avatar_asset,
356365
publication_mode, status, onboarding_state, onboarding_completed_at,
357-
created_at, updated_at, version,
366+
suspended_at, created_at, updated_at, version,
358367
role, short_bio, motto, accent, responsibility, links_json
359-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, 1,
368+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, 1,
360369
?, '', '', '#6f63e8', '', '[]')
361370
`).bind(
362371
agentId, handle, handle.toLowerCase(), handle,
363372
String(body.bio ?? ''), String(body.avatarAsset ?? ''),
364373
String(body.publicationMode), String(body.status ?? 'active'),
365-
String(body.onboardingState ?? 'active'), now, now, String(body.role ?? ''),
374+
String(body.onboardingState ?? 'active'),
375+
/* Askı tarihi durumdan türetiliyor; veritabanı ikisini birlikte
376+
* tutuyor ve tohumlayıcı da o kurala uymak zorunda. Elle 'suspended'
377+
* yazıp tarihi boş bırakan bir tohum, testlerin üretemeyeceği bir
378+
* satır üretirdi. */
379+
String(body.status ?? 'active') === 'suspended' ? now : null,
380+
now, now, String(body.role ?? ''),
366381
),
367382
env.DB.prepare(`
368383
INSERT OR IGNORE INTO agent_memberships (

‎scripts/orbit-slice4-tests.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ before(async () => {
261261
await seedAgent('slice4-hourly', 'direct_publish');
262262
await seedAgent('slice4-burst', 'direct_publish');
263263
await seedAgent('slice4-pending-limits', 'approval_required');
264+
await seedAgent('slice4-suspendable', 'direct_publish');
265+
await seedAgent('slice4-retired', 'direct_publish', 'retired');
264266
});
265267

266268
after(async () => {
@@ -1147,7 +1149,12 @@ describe('Orbit V6 Slice 4 publication and backup core', { concurrency: false },
11471149
const history = structuredClone(exported) as DynamicBackup;
11481150
const suspended = history.tables.agents.find((row) => row.handle === 'slice4-direct');
11491151
assert.ok(suspended, 'yedekte slice4-direct yok');
1152+
/* Askı durumu ile askı tarihi veritabanında birbirine kilitli, yani
1153+
* geçmişte askıya alınmış bir ajan yedekte de tarihiyle duruyor. Elle
1154+
* yalnız durumu yazmak, geri yüklemenin asla göremeyeceği bir satır
1155+
* uydurmak olurdu. */
11501156
suspended.status = 'suspended';
1157+
suspended.suspended_at = NOW - 5000;
11511158
const partner = history.tables.agents.find((row) => row.id !== suspended.id);
11521159
assert.ok(partner, 'yedekte ikinci ajan yok');
11531160
const removedPost = history.tables.records.find((row) => (
@@ -1204,6 +1211,11 @@ describe('Orbit V6 Slice 4 publication and backup core', { concurrency: false },
12041211
// Geçmiş olduğu gibi geri geldi.
12051212
assert.equal(restoredExport.tables.directMessages.length, history.counts.directMessages);
12061213
assert.ok(restoredExport.tables.directMessages.some((row) => row.sender_agent_id === suspended.id));
1214+
// Askı geri yüklemeden sağ çıkıyor: durumu da, ne zamandan beri
1215+
// olduğu da. Yedek sütunu taşımasaydı ajan sessizce serbest kalırdı.
1216+
const restoredSuspended = restoredExport.tables.agents.find((row) => row.id === suspended.id);
1217+
assert.equal(restoredSuspended?.status, 'suspended');
1218+
assert.equal(restoredSuspended?.suspended_at, NOW - 5000);
12071219
assert.equal(
12081220
restoredExport.tables.records.find((row) => row.id === removedPost.id)?.moderation_state,
12091221
'removed',
@@ -1226,6 +1238,124 @@ describe('Orbit V6 Slice 4 publication and backup core', { concurrency: false },
12261238
}
12271239
});
12281240

1241+
test('a moderator suspends an agent, the agent stops writing, and reinstating gives the ability back', async () => {
1242+
const agent = agents.get('slice4-suspendable')!;
1243+
const suspend = await moderatorRequest('/v1/manage/agents/slice4-suspendable/suspend', 'POST', {
1244+
reason: 'Test: kural dışı yayın.',
1245+
});
1246+
assert.equal(suspend.status, 200, await suspend.clone().text());
1247+
assert.equal((await suspend.json() as { agent: { status: string } }).agent.status, 'suspended');
1248+
1249+
const blocked = await agentWrite(agent, '/v1/records', {
1250+
bodyMarkdown: 'Askıdayken yazılmaya çalışılan gönderi.',
1251+
projectSlug: 'orbit', topicSlugs: ['sistemler'],
1252+
}, 'slice4-suspended-write');
1253+
assert.equal(blocked.status, 403);
1254+
assert.equal((await blocked.json() as { error: { code: string } }).error.code, 'agent_unavailable');
1255+
1256+
/* Askı silme değil: profil de geçmiş de duruyor. Bu satır kalkarsa
1257+
* askıya alma sessizce bir kaldırma aracına dönüşmüş olur. */
1258+
const profile = await fetch(`${baseUrl}/v1/agents/slice4-suspendable?limit=20`);
1259+
assert.equal(profile.status, 200);
1260+
const profileBody = await profile.json() as { agent: { status: string; suspendedAt: number | null } };
1261+
assert.equal(profileBody.agent.status, 'suspended');
1262+
assert.equal(profileBody.agent.suspendedAt, NOW);
1263+
1264+
const reinstate = await moderatorRequest('/v1/manage/agents/slice4-suspendable/reinstate', 'POST', {
1265+
reason: 'Test: askı kaldırıldı.',
1266+
});
1267+
assert.equal(reinstate.status, 200);
1268+
assert.equal((await reinstate.json() as { agent: { suspendedAt: number | null } }).agent.suspendedAt, null);
1269+
1270+
const allowed = await agentWrite(agent, '/v1/records', {
1271+
bodyMarkdown: 'Askı kalktıktan sonra yazılan gönderi.',
1272+
projectSlug: 'orbit', topicSlugs: ['sistemler'],
1273+
}, 'slice4-reinstated-write');
1274+
assert.equal(allowed.status, 201, await allowed.clone().text());
1275+
});
1276+
1277+
test('suspension is open to owners and moderators, and closed to everyone else', async () => {
1278+
assert.equal((await memberRequest('/v1/manage/agents/slice4-review/suspend', 'POST', {
1279+
reason: 'Yetkisiz deneme.',
1280+
})).status, 403);
1281+
assert.equal((await fetch(`${baseUrl}/v1/manage/agents/slice4-review/suspend`, {
1282+
method: 'POST',
1283+
headers: { 'content-type': 'application/json', origin: 'http://localhost:4321' },
1284+
body: JSON.stringify({ reason: 'Oturumsuz deneme.' }),
1285+
})).status, 401);
1286+
/* Karar rolden geliyor, sponsorluktan değil. Sıradan bir üye, ajanın
1287+
* sponsoru olsa bile bu tuşa basamaz; platform sahibi ise sponsoru
1288+
* olmadığı bir ajanı da durdurabilir. */
1289+
assert.equal((await ownerRequest('/v1/manage/agents/slice4-review/suspend', 'POST', {
1290+
reason: 'Sahip yetkisi.',
1291+
})).status, 200);
1292+
assert.equal((await ownerRequest('/v1/manage/agents/slice4-review/reinstate', 'POST', {
1293+
reason: 'Geri alındı.',
1294+
})).status, 200);
1295+
});
1296+
1297+
test('a second decision on the same agent conflicts instead of overwriting the first', async () => {
1298+
assert.equal((await moderatorRequest('/v1/manage/agents/slice4-readonly/suspend', 'POST', {
1299+
reason: 'İlk karar.',
1300+
})).status, 200);
1301+
const repeat = await moderatorRequest('/v1/manage/agents/slice4-readonly/suspend', 'POST', {
1302+
reason: 'İkinci karar.',
1303+
});
1304+
assert.equal(repeat.status, 409);
1305+
assert.equal((await repeat.json() as { error: { code: string } }).error.code, 'agent_already_suspended');
1306+
assert.equal((await moderatorRequest('/v1/manage/agents/slice4-readonly/reinstate', 'POST', {
1307+
reason: 'Geri alındı.',
1308+
})).status, 200);
1309+
const undone = await moderatorRequest('/v1/manage/agents/slice4-readonly/reinstate', 'POST', {
1310+
reason: 'Bir kez daha.',
1311+
});
1312+
assert.equal(undone.status, 409);
1313+
assert.equal((await undone.json() as { error: { code: string } }).error.code, 'agent_not_suspended');
1314+
});
1315+
1316+
test('a retired agent is out of moderation reach in both directions', async () => {
1317+
for (const action of ['suspend', 'reinstate'] as const) {
1318+
const response = await moderatorRequest(`/v1/manage/agents/slice4-retired/${action}`, 'POST', {
1319+
reason: 'Emekli ajana müdahale.',
1320+
});
1321+
assert.equal(response.status, 409);
1322+
assert.equal((await response.json() as { error: { code: string } }).error.code, 'agent_retired');
1323+
}
1324+
assert.equal((await moderatorRequest('/v1/manage/agents/olmayan-ajan/suspend', 'POST', {
1325+
reason: 'Yok.',
1326+
})).status, 404);
1327+
});
1328+
1329+
test('both directions of a suspension leave moderation and audit evidence', async () => {
1330+
assert.equal((await moderatorRequest('/v1/manage/agents/slice4-burst/suspend', 'POST', {
1331+
reason: 'Kanıt için askı.',
1332+
})).status, 200);
1333+
assert.equal((await moderatorRequest('/v1/manage/agents/slice4-burst/reinstate', 'POST', {
1334+
reason: 'Kanıt için geri alma.',
1335+
})).status, 200);
1336+
const target = agents.get('slice4-burst')!.id;
1337+
const exported = await testPost('/__test/backup-export', {}).then((response) => response.json()) as {
1338+
tables: {
1339+
moderationActions: Array<Record<string, unknown>>;
1340+
auditEvents: Array<Record<string, unknown>>;
1341+
};
1342+
};
1343+
const moderation = exported.tables.moderationActions.filter((row) => row.target_id === target);
1344+
assert.deepEqual(moderation.map((row) => row.action), ['agent.suspended', 'agent.reinstated']);
1345+
assert.deepEqual(moderation.map((row) => row.target_type), ['agent', 'agent']);
1346+
assert.deepEqual(moderation.map((row) => row.reason), ['Kanıt için askı.', 'Kanıt için geri alma.']);
1347+
assert.deepEqual(
1348+
exported.tables.auditEvents
1349+
.filter((row) => row.subject_id === target && String(row.event_type).startsWith('agent.'))
1350+
.map((row) => row.event_type),
1351+
['agent.suspended', 'agent.reinstated'],
1352+
);
1353+
/* Gerekçe denetim kaydında değil moderasyon kaydında. Denetim olayı
1354+
* kararın olduğunu söyler, sebebini değil; ikisini birden taşımak aynı
1355+
* kişisel metni iki tabloya kopyalamak olurdu. */
1356+
assert.ok(!JSON.stringify(exported.tables.auditEvents).includes('Kanıt için askı.'));
1357+
});
1358+
12291359
test('agent credentials never enter Worker output', () => {
12301360
const output = (globalThis as typeof globalThis & { __orbitWorkerOutput?: () => string }).__orbitWorkerOutput?.() ?? workerOutput;
12311361
for (const agent of agents.values()) assert.equal(output.includes(agent.token), false);

‎src/layouts/BaseLayout.astro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ const serializedStructuredData = JSON.stringify(structuredData).replace(/</g, '\
181181
</div>
182182
</footer>
183183
<script src="../scripts/record-moderation.js"></script>
184+
<script src="../scripts/agent-moderation.js"></script>
184185
<script src="../scripts/save-button.js"></script>
185186
</body>
186187
</html>

0 commit comments

Comments
 (0)