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
146 changes: 73 additions & 73 deletions bin/knowledge.js

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18665,7 +18665,28 @@ var PG_MIGRATIONS = [
END IF;
RETURN NEW;
END
$knowledge_guarded_adoption_claim_once$ LANGUAGE plpgsql`
$knowledge_guarded_adoption_claim_once$ LANGUAGE plpgsql`,
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_id_immutable()
RETURNS TRIGGER AS $knowledge_guarded_item_id_immutable$
BEGIN
IF OLD.id IS DISTINCT FROM NEW.id
AND NULLIF(
current_setting('hasna.knowledge_guarded_adoption_key', true),
''
) IS NOT NULL THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_id_immutable$ LANGUAGE plpgsql`,
`DROP TRIGGER IF EXISTS trg_knowledge_guarded_00_item_id_immutable
ON knowledge_items`,
`CREATE TRIGGER trg_knowledge_guarded_00_item_id_immutable
BEFORE UPDATE OF id ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_id_immutable()`,
`ALTER TABLE knowledge_items
ENABLE ALWAYS TRIGGER trg_knowledge_guarded_00_item_id_immutable`
];
// src/serve.ts
import { readFileSync as readFileSync5 } from "fs";
Expand Down
23 changes: 22 additions & 1 deletion dist/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3795,7 +3795,28 @@ var PG_MIGRATIONS = [
END IF;
RETURN NEW;
END
$knowledge_guarded_adoption_claim_once$ LANGUAGE plpgsql`
$knowledge_guarded_adoption_claim_once$ LANGUAGE plpgsql`,
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_id_immutable()
RETURNS TRIGGER AS $knowledge_guarded_item_id_immutable$
BEGIN
IF OLD.id IS DISTINCT FROM NEW.id
AND NULLIF(
current_setting('hasna.knowledge_guarded_adoption_key', true),
''
) IS NOT NULL THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_id_immutable$ LANGUAGE plpgsql`,
`DROP TRIGGER IF EXISTS trg_knowledge_guarded_00_item_id_immutable
ON knowledge_items`,
`CREATE TRIGGER trg_knowledge_guarded_00_item_id_immutable
BEFORE UPDATE OF id ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_id_immutable()`,
`ALTER TABLE knowledge_items
ENABLE ALWAYS TRIGGER trg_knowledge_guarded_00_item_id_immutable`
];
export {
wrapExecutor,
Expand Down
26 changes: 26 additions & 0 deletions src/db/pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1398,4 +1398,30 @@ export const PG_MIGRATIONS: string[] = [
RETURN NEW;
END
$knowledge_guarded_adoption_claim_once$ LANGUAGE plpgsql`,

// The adoption transition intentionally permits binding fields to change,
// but never the row identity. This trigger sorts before the authority
// trigger so a live adoption claim cannot authorize a primary-key rewrite,
// while ordinary unbound-row behavior remains unchanged.
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_id_immutable()
RETURNS TRIGGER AS $knowledge_guarded_item_id_immutable$
BEGIN
IF OLD.id IS DISTINCT FROM NEW.id
AND NULLIF(
current_setting('hasna.knowledge_guarded_adoption_key', true),
''
) IS NOT NULL THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_id_immutable$ LANGUAGE plpgsql`,
`DROP TRIGGER IF EXISTS trg_knowledge_guarded_00_item_id_immutable
ON knowledge_items`,
`CREATE TRIGGER trg_knowledge_guarded_00_item_id_immutable
BEFORE UPDATE OF id ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_id_immutable()`,
`ALTER TABLE knowledge_items
ENABLE ALWAYS TRIGGER trg_knowledge_guarded_00_item_id_immutable`,
];
74 changes: 74 additions & 0 deletions tests/guarded-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,80 @@ describe('FCAME-1 guarded Knowledge writer', () => {
expect((await writer().readBindingState(staleTarget)).state).toBe('legacy_unbound');
});

test('database trigger refuses a primary-key change under a valid live adoption claim', async () => {
const target = 'k_fcame_adoption_trigger_primary_key';
const replacement = 'k_fcame_adoption_trigger_primary_key_replacement';
const content = 'primary-key-stable legacy content';
await createLegacyItem(target, content);
const before = await itemSnapshot(target);
const deterministicKey = computeKnowledgeGuardedAdoptionDeterministicKey({
action: 'adopt',
operation_id: 'op-adoption-trigger-primary-key-change',
step_id: 'step-adopt',
target_id: target,
binding: BINDING,
expected_version: 1,
expected_content_sha256: createHash('sha256').update(content).digest('hex'),
adoption_receipt_id: null,
});
const receiptId = computeKnowledgeGuardedAdoptionReceiptId(deterministicKey);
await db.query(
`INSERT INTO knowledge_guarded_adoption_claims (
deterministic_key, planned_receipt_id, operation_id, step_id, action, target_id,
authority_classification, authority_id, tenant_id, scope, parent_id,
expected_version, expected_content_sha256, adoption_receipt_id
) VALUES ($1,$2,'op-adoption-trigger-primary-key-change','step-adopt','adopt',
$3,$4,$5,$6,$7,$8,1,$9,NULL)`,
[
deterministicKey,
receiptId,
target,
BINDING.authority.classification,
BINDING.authority.authority_id,
BINDING.tenant_id,
BINDING.scope,
BINDING.parent_id,
createHash('sha256').update(content).digest('hex'),
],
);
await db.query(
`SELECT set_config('hasna.knowledge_guarded_adoption_key', $1, false)`,
[deterministicKey],
);
try {
await expect(db.query(
`UPDATE knowledge_items SET
id = $1,
authority_classification = $2,
authority_id = $3,
tenant_id = $4,
scope = $5,
parent_id = $6,
guarded_adoption_receipt_id = $7
WHERE id = $8`,
[
replacement,
BINDING.authority.classification,
BINDING.authority.authority_id,
BINDING.tenant_id,
BINDING.scope,
BINDING.parent_id,
receiptId,
target,
],
)).rejects.toThrow(/identity and binding are immutable/i);
} finally {
await db.query(`SELECT set_config('hasna.knowledge_guarded_adoption_key', '', false)`);
}

expect(await itemSnapshot(target)).toEqual(before);
expect((await writer().readBindingState(target)).state).toBe('legacy_unbound');
expect((await db.query(
`SELECT 1 FROM knowledge_items WHERE id = $1`,
[replacement],
)).rows).toHaveLength(0);
});

test('adoption claim binds only its planned receipt once', async () => {
const unrelatedKey = computeKnowledgeGuardedAdoptionDeterministicKey({
action: 'adopt',
Expand Down
Loading