فهرست مقاله
رسیدن یک رکورد به موعدِ قراردادی، با حذفشدن آن یکی نیست. پیش از هر سازوکار اجرایی، میتوان یک برنامهٔ آفلاین ساخت که فقط رکوردهای نیازمند بازبینی را مشخص کند. این درس زمان ایجاد، مدت نگهداری و یک پرچم توقف را روی شش رکورد ساختگی میسنجد. هیچ فایل، پایگاه داده، نسخهٔ پشتیبان یا دادهٔ واقعی خوانده یا حذف نمیشود؛ مدتها نیز توصیهٔ حقوقی نیستند.
۱. زمان دقیق و مدت را دو ورودی جدا بگیریم

هر رکورد چهار فیلد دارد: شناسه، created_at، ttl_seconds و hold. زمان بررسی نیز با پارامتر now وارد میشود؛ برنامه ساعت رایانه را نمیخواند. این انتخاب باعث میشود همان ورودی در اجرای بعدی همان پاسخ را بدهد. برای آزمونِ مرز فردا، لازم نیست ساعت سیستم یا تاریخ دفتر ثبت تغییر کند.
قالب زمان دقیقاً سال، ماه، روز، T بزرگ، ساعت، دقیقه، ثانیه و Z بزرگ است؛ مانند زمان ساختگی 2026-10-16T12:00:00Z. در RFC 3339 شکلهای بیشتری از جمله offset و کسر ثانیه وجود دارند؛ قرارداد ما فقط زیرمجموعهٔ اعلامشدهٔ UTC با ثانیهٔ کامل را میپذیرد. پس پشتیبانی از همهٔ قالب استاندارد ادعا نمیشود.
مستندات datetime پایتون زمان آگاه به منطقه را از زمانِ بدون منطقه جدا میکند. کد ما شیء UTC میسازد و مدت را با ثانیهٔ صحیح جمع میکند. تقویم محلی، تغییر ساعت، ثانیهٔ کبیسه و تبدیلِ نمایشیِ شمسی در این محاسبه نیستند؛ تاریخ نمایشی مقاله، قرارداد زمان رکورد را تغییر نمیدهد.
۲. بازهٔ نگهداری را نیمهباز تعریف کنیم

موعد برابر زمان ایجاد بهعلاوهٔ مدت است. تا پیش از موعد، حالت retain میدهیم؛ در خود موعد و پس از آن، اگر توقفی نباشد، review_due میشود. بنابراین بازهٔ retain از زمان ایجاد آغاز و درست پیش از موعد تمام میشود. نامِ review_due فقط «نیازمند بازبینی» است؛ مجوز حذف یا رسید انجام عمل نیست.
زمان بررسیِ نمونه، دوازده UTC است. R01 در ۱۱:۵۹:۰۰ ایجاد شده و مدت شصت ثانیه دارد؛ موعدش دقیقاً دوازده و حالتش review_due است. R02 یک ثانیه دیرتر ایجاد شده؛ موعد ۱۲:۰۰:۰۱ و یک ثانیه باقیمانده دارد. R03 یک ثانیه زودتر ساخته شده؛ موعدش ۱۱:۵۹:۵۹ بوده و یک ثانیه از آن گذشته است.
مدت صفر را هم صریحاً مجاز گذاشتهایم: R05 در خود زمان بررسی ساخته شده و بلافاصله نامزد بازبینی است. این سیاستِ آزمایش است، نه پیشنهاد زمان نگهداری دادهٔ سازمان. سقف مدت نیز ۳۱ ضربدر ۸۶۴۰۰ ثانیه است؛ آن را «یک ماه تقویمی» نمینامیم، چون طول ماهها برابر نیست.
۳. توقف قراردادی را از گذشتن موعد جدا کنیم

R04 در ۱۱:۵۰ ایجاد شده و مدت شصت ثانیه دارد؛ موعدش ۱۱:۵۱ است. در دوازده، ۵۴۰ ثانیه از موعد گذشته، اما hold آن True است. حالت on_hold میگیرد و وارد فهرست review_due نمیشود. خود موعد همچنان گزارش میشود تا گذشتن زمان پنهان نماند؛ پرچم توقف، ساعت را عقب نمیبرد.
این پرچم فقط دستور ساختگیِ ورودیِ تمرین است. معنای «توقف» در سامانهٔ واقعی به تصمیمهای خارج از این کد نیاز دارد؛ از یک bool نمیتوان ضرورت حقوقی، مجوز دسترسی یا حفظ امن داده را نتیجه گرفت. برنامه حتی برای رکوردِ متوقفشده هم مدت نامعتبر یا زمان ایجاد آینده را رد میکند؛ توقف، اعتبارسنجی را دور نمیزند.
در قرارداد تاریخچهٔ ارجاع تماس، کمکردن تعداد نوبتهای همراه انتقال موضوع دیگری بود. اینجا تعداد پیام یا حافظهٔ مکالمه را کوتاه نمیکنیم. همچنین موعد نگهداری یک رکورد با اعتبار زمانی سند در RAG یکی نیست؛ ممکن است سیاست ذخیرهسازی و اجازهٔ استفاده از محتوا به دو تصمیم متفاوت نیاز داشته باشند.
۴. کد کامل و مرجع تقویمی مستقل را اجرا کنیم

فایل retention_expiry.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. قالب با رقم ASCII و جداکنندههای ثابت بررسی میشود؛ سازندهٔ datetime نیز روز و ساعت را اعتبارسنجی میکند. تاریخ ۳۰ فوریه، ساعت ۲۴ و ثانیهٔ ۶۰ پذیرفته نیستند. محدودیت آخر با مدل زمانی پایتون و قراردادِ این نمونه هماهنگ است، نه ادعای نبود ثانیهٔ کبیسه در استاندارد.
"""Offline authored retention-review plan, never delete or legal advice."""
from datetime import datetime, timedelta, UTC
import json
import platform
import re
STAMP = re.compile(r'([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z\Z')
MAX_TTL = 31 * 86400
def parse_utc(text):
if type(text) is not str or not (match := STAMP.fullmatch(text)):
raise ValueError('timestamp_format')
try:
return datetime(*(int(value) for value in match.groups()), tzinfo=UTC)
except ValueError:
raise ValueError('timestamp_value') from None
def format_utc(value):
return f'{value.year:04}-{value.month:02}-{value.day:02}T{value.hour:02}:{value.minute:02}:{value.second:02}Z'
def retention_plan(now_utc, records):
now = parse_utc(now_utc)
if type(records) is not list or len(records) > 32:
raise ValueError('records')
prepared, seen = [], set()
for record in records:
if type(record) is not dict or set(record) != {'id', 'created_at', 'ttl_seconds', 'hold'}:
raise ValueError('record_fields')
identifier, ttl, hold = record['id'], record['ttl_seconds'], record['hold']
if type(identifier) is not str or not 1 <= len(identifier) <= 24 or any(c.isspace() for c in identifier):
raise ValueError('id')
if identifier in seen:
raise ValueError('duplicate_id')
seen.add(identifier)
if type(ttl) is not int or not 0 <= ttl <= MAX_TTL:
raise ValueError('ttl_seconds')
if type(hold) is not bool:
raise ValueError('hold')
created = parse_utc(record['created_at'])
if created > now:
raise ValueError('future_created_at')
try:
deadline = created + timedelta(seconds=ttl)
except OverflowError:
raise ValueError('deadline_overflow') from None
prepared.append((identifier, created, ttl, hold, deadline))
output = []
for identifier, created, ttl, hold, deadline in sorted(prepared):
state = 'on_hold' if hold else 'retain' if now < deadline else 'review_due'
output.append({'id': identifier, 'created_at': format_utc(created), 'ttl_seconds': ttl,
'deadline_utc': format_utc(deadline), 'state': state,
'seconds_until_deadline': (deadline - now) // timedelta(seconds=1)})
return {'as_of_utc': format_utc(now), 'records': output,
'review_due_ids': [row['id'] for row in output if row['state'] == 'review_due'],
'actual_deletions': 0}
def gregorian_seconds_oracle(text):
"""Independent integer Gregorian days, no datetime/timedelta/parser helper."""
year, month, day = int(text[:4]), int(text[5:7]), int(text[8:10])
hour, minute, second = int(text[11:13]), int(text[14:16]), int(text[17:19])
prior_year = year - 1
days = 365 * prior_year + prior_year // 4 - prior_year // 100 + prior_year // 400
month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
days += sum(month_days[:month - 1]) + day - 1
if month > 2 and year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
days += 1
return days * 86400 + hour * 3600 + minute * 60 + second
def rejects(call, message):
try:
call()
except ValueError as error:
return str(error) == message
return False
def run_example():
now = '2026-10-16T12:00:00Z'
records = [
{'id': 'R01', 'created_at': '2026-10-16T11:59:00Z', 'ttl_seconds': 60, 'hold': False},
{'id': 'R02', 'created_at': '2026-10-16T11:59:01Z', 'ttl_seconds': 60, 'hold': False},
{'id': 'R03', 'created_at': '2026-10-16T11:58:59Z', 'ttl_seconds': 60, 'hold': False},
{'id': 'R04', 'created_at': '2026-10-16T11:50:00Z', 'ttl_seconds': 60, 'hold': True},
{'id': 'R05', 'created_at': now, 'ttl_seconds': 0, 'hold': False},
{'id': 'R06', 'created_at': '2026-10-16T11:59:00Z', 'ttl_seconds': 3600, 'hold': False},
]
original = json.dumps(records, sort_keys=True)
result = retention_plan(now, records)
by_id = {row['id']: row for row in result['records']}
def changed(**fields):
return [{**records[0], **fields}]
cases, oracle_ok = 0, True
for start in ['2024-02-28T23:59:30Z', '2025-12-31T23:59:30Z',
'1900-02-28T23:59:30Z', '2000-02-28T23:59:30Z']:
for elapsed in [59, 60, 61]:
current = format_utc(parse_utc(start) + timedelta(seconds=elapsed))
for hold in [False, True]:
record = {'id': 'X', 'created_at': start, 'ttl_seconds': 60, 'hold': hold}
row = retention_plan(current, [record])['records'][0]
difference = gregorian_seconds_oracle(start) + 60 - gregorian_seconds_oracle(current)
expected = 'on_hold' if hold else 'retain' if difference > 0 else 'review_due'
oracle_ok &= row['state'] == expected and row['seconds_until_deadline'] == difference
oracle_ok &= gregorian_seconds_oracle(row['deadline_utc']) == gregorian_seconds_oracle(start) + 60
cases += 1
malformed = [now[:-1], now.lower(), '2026-10-16 12:00:00Z', '2026-10-16T12:00:00+00:00',
'2026-10-16T12:00:00.0Z', '2026-1-16T12:00:00Z']
invalid_calendar = ['2026-02-30T00:00:00Z', '1900-02-29T00:00:00Z',
'2026-10-16T24:00:00Z', '2016-12-31T23:59:60Z', '0000-01-01T00:00:00Z']
many = [{**records[0], 'id': f'R{i:02}'} for i in range(32)]
copied = retention_plan(now, records); copied['records'][0]['state'] = 'changed'; copied['review_due_ids'].clear()
checks = {
'hand_three_due_two_retained_one_hold': [row['state'] for row in result['records']] == ['review_due', 'retain', 'review_due', 'on_hold', 'review_due', 'retain'] and result['review_due_ids'] == ['R01', 'R03', 'R05'],
'hand_deadline_and_exact_remaining_seconds': by_id['R01']['deadline_utc'] == now and [by_id[key]['seconds_until_deadline'] for key in ['R01', 'R02', 'R03', 'R04', 'R05', 'R06']] == [0, 1, -1, -540, 0, 3540],
'one_second_before_deadline_retains': by_id['R02']['state'] == 'retain' and by_id['R02']['seconds_until_deadline'] == 1,
'exact_deadline_is_review_due_not_retained': by_id['R01']['state'] == 'review_due' and by_id['R01']['seconds_until_deadline'] == 0,
'one_second_after_deadline_is_review_due': by_id['R03']['state'] == 'review_due' and by_id['R03']['seconds_until_deadline'] == -1,
'hold_prevents_review_due_even_after_deadline': by_id['R04']['state'] == 'on_hold' and 'R04' not in result['review_due_ids'],
'zero_ttl_is_due_at_creation': by_id['R05']['state'] == 'review_due' and by_id['R05']['deadline_utc'] == now,
'leap_and_century_calendar_hand_values': format_utc(parse_utc('2024-02-28T23:59:59Z') + timedelta(seconds=1)) == '2024-02-29T00:00:00Z' and format_utc(parse_utc('1900-02-28T23:59:59Z') + timedelta(seconds=1)) == '1900-03-01T00:00:00Z',
'strict_utc_second_format_excludes_offset_and_fraction': all(rejects(lambda value=value: parse_utc(value), 'timestamp_format') for value in malformed),
'invalid_calendar_and_leap_second_reject': all(rejects(lambda value=value: parse_utc(value), 'timestamp_value') for value in invalid_calendar),
'ttl_type_sign_and_upper_bound_reject': all(rejects(lambda value=value: retention_plan(now, changed(ttl_seconds=value)), 'ttl_seconds') for value in [True, -1, 60.0, '60', MAX_TTL + 1]) and retention_plan(now, changed(ttl_seconds=MAX_TTL))['records'][0]['state'] == 'retain',
'future_creation_rejects_even_when_held': all(rejects(lambda hold=hold: retention_plan(now, changed(created_at='2026-10-16T12:00:01Z', hold=hold)), 'future_created_at') for hold in [False, True]),
'hold_requires_actual_bool': all(rejects(lambda value=value: retention_plan(now, changed(hold=value)), 'hold') for value in [0, 1, 'false', None]),
'duplicate_id_and_record_schema_reject': rejects(lambda: retention_plan(now, [records[0], records[0]]), 'duplicate_id') and rejects(lambda: retention_plan(now, [{**records[0], 'extra': 1}]), 'record_fields') and rejects(lambda: retention_plan(now, None), 'records'),
'unrepresentable_deadline_rejects': rejects(lambda: retention_plan('9999-12-31T23:59:59Z', changed(created_at='9999-12-31T23:59:59Z', ttl_seconds=1)), 'deadline_overflow'),
'bounded_record_count_includes_32_rejects_33': len(retention_plan(now, many)['records']) == 32 and rejects(lambda: retention_plan(now, many + [{**records[0], 'id': 'extra'}]), 'records'),
'empty_and_reordered_inputs_no_deletion_or_mutation': retention_plan(now, [])['review_due_ids'] == [] and result['actual_deletions'] == 0 and retention_plan(now, records) == result and retention_plan(now, list(reversed(records))) == result and json.dumps(records, sort_keys=True) == original,
'independent_integer_gregorian_boundary_oracle': cases == 24 and bool(oracle_ok),
}
assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
'scope': 'Authored offline retention-review planner, no deletion/legal compliance/production clock guarantee',
'results': {'input_records': records, 'plan': result, 'oracle_boundary_cases_inside_one_group': cases,
'limits': {'max_records': 32, 'ttl_seconds': f'exact int0..{MAX_TTL}',
'timestamp': 'strict uppercase UTC seconds only, no leap seconds/offset/fraction'}}, 'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
هجده گروه اجرایی شامل مرزِ قبل، خود و بعد از موعد، مدت صفر، توقف، آیندهبودن ایجاد، نوع نامعتبر و سرریزند. مرجع مستقل، تعداد روزهای تقویم گریگوری را با چرخههای چهار، صد و چهارصدساله و طول ماهها به ثانیهٔ صحیح تبدیل میکند؛ از datetime، timedelta یا تحلیلگر مسیر اصلی استفاده نمیکند. این مرجع یک سامانهٔ ساعت واقعی یا تقویم حقوقی نیست.
برای چهار شروعِ نزدیک مرزِ سال و فوریه، سه فاصلهٔ ۵۹، شصت و ۶۱ ثانیه و دو مقدار توقف مقایسه میشوند: بیستوچهار حالت در یک گروهِ مرجع. نمونهٔ ۱۹۰۰ به مارس میرسد ولی ۲۰۰۰ و ۲۰۲۴ قواعد کبیسهٔ متفاوتی دارند. تعداد حالتها با تعداد گروهها فرق دارد و اجرای دوباره، آزمون جدید محسوب نمیشود.
۵. خروجی بازبینی و چهار پرسش پاسخدار را ثبت کنیم

در خروجی، R01، R03 و R05 نامزد بازبینیاند؛ R02 و R06 هنوز retain و R04 متوقفاند. R06 با مدت ۳۶۰۰ ثانیه، ۳۵۴۰ ثانیه تا موعد دارد. شناسهها مرتب گزارش میشوند؛ این ترتیب، اولویت حذف یا اهمیت داده نیست. مقدار actual_deletions صفر است، چون هیچ مسیر حذف در برنامه وجود ندارد.
{
"python": "3.14.5",
"dependencies": "Python standard library only",
"scope": "Authored offline retention-review planner, no deletion/legal compliance/production clock guarantee",
"results": {
"input_records": [
{
"id": "R01",
"created_at": "2026-10-16T11:59:00Z",
"ttl_seconds": 60,
"hold": false
},
{
"id": "R02",
"created_at": "2026-10-16T11:59:01Z",
"ttl_seconds": 60,
"hold": false
},
{
"id": "R03",
"created_at": "2026-10-16T11:58:59Z",
"ttl_seconds": 60,
"hold": false
},
{
"id": "R04",
"created_at": "2026-10-16T11:50:00Z",
"ttl_seconds": 60,
"hold": true
},
{
"id": "R05",
"created_at": "2026-10-16T12:00:00Z",
"ttl_seconds": 0,
"hold": false
},
{
"id": "R06",
"created_at": "2026-10-16T11:59:00Z",
"ttl_seconds": 3600,
"hold": false
}
],
"plan": {
"as_of_utc": "2026-10-16T12:00:00Z",
"records": [
{
"id": "R01",
"created_at": "2026-10-16T11:59:00Z",
"ttl_seconds": 60,
"deadline_utc": "2026-10-16T12:00:00Z",
"state": "review_due",
"seconds_until_deadline": 0
},
{
"id": "R02",
"created_at": "2026-10-16T11:59:01Z",
"ttl_seconds": 60,
"deadline_utc": "2026-10-16T12:00:01Z",
"state": "retain",
"seconds_until_deadline": 1
},
{
"id": "R03",
"created_at": "2026-10-16T11:58:59Z",
"ttl_seconds": 60,
"deadline_utc": "2026-10-16T11:59:59Z",
"state": "review_due",
"seconds_until_deadline": -1
},
{
"id": "R04",
"created_at": "2026-10-16T11:50:00Z",
"ttl_seconds": 60,
"deadline_utc": "2026-10-16T11:51:00Z",
"state": "on_hold",
"seconds_until_deadline": -540
},
{
"id": "R05",
"created_at": "2026-10-16T12:00:00Z",
"ttl_seconds": 0,
"deadline_utc": "2026-10-16T12:00:00Z",
"state": "review_due",
"seconds_until_deadline": 0
},
{
"id": "R06",
"created_at": "2026-10-16T11:59:00Z",
"ttl_seconds": 3600,
"deadline_utc": "2026-10-16T12:59:00Z",
"state": "retain",
"seconds_until_deadline": 3540
}
],
"review_due_ids": [
"R01",
"R03",
"R05"
],
"actual_deletions": 0
},
"oracle_boundary_cases_inside_one_group": 24,
"limits": {
"max_records": 32,
"ttl_seconds": "exact int0..2678400",
"timestamp": "strict uppercase UTC seconds only, no leap seconds/offset/fraction"
}
},
"checks": {
"hand_three_due_two_retained_one_hold": true,
"hand_deadline_and_exact_remaining_seconds": true,
"one_second_before_deadline_retains": true,
"exact_deadline_is_review_due_not_retained": true,
"one_second_after_deadline_is_review_due": true,
"hold_prevents_review_due_even_after_deadline": true,
"zero_ttl_is_due_at_creation": true,
"leap_and_century_calendar_hand_values": true,
"strict_utc_second_format_excludes_offset_and_fraction": true,
"invalid_calendar_and_leap_second_reject": true,
"ttl_type_sign_and_upper_bound_reject": true,
"future_creation_rejects_even_when_held": true,
"hold_requires_actual_bool": true,
"duplicate_id_and_record_schema_reject": true,
"unrepresentable_deadline_rejects": true,
"bounded_record_count_includes_32_rejects_33": true,
"empty_and_reordered_inputs_no_deletion_or_mutation": true,
"independent_integer_gregorian_boundary_oracle": true
}
}
قرارداد حداکثر سیودو رکورد با شناسهٔ یکتا دارد. bool بهعنوان مدت صحیح پذیرفته نمیشود؛ hold باید خودِ bool باشد. مدت منفی، کسری یا بیش از سقف، فیلد ناشناخته و سرریز موعد خطا میدهند. اگر ایجاد فقط یک ثانیه در آینده باشد، کل برنامه خطا میدهد؛ رکورد مشکوک بیاعلان قدیمی یا قابلحذف فرض نمیشود.
پرسش اول: در خودِ موعد، رکورد هنوز retain است؟ پاسخ: خیر؛ در قرارداد نیمهباز، R01 با اختلاف صفر review_due است، مشروط به اینکه hold نداشته باشد.
پرسش دوم: چرا R04 با گذشتن ۵۴۰ ثانیه نامزد نیست؟ پاسخ: hold آن True است و on_hold میگیرد؛ اختلاف منفی همچنان در گزارش میماند. این پاسخ فقط دربارهٔ پرچمِ قراردادیِ مثال است.
پرسش سوم: اگر رکورد در آینده ساخته شده باشد چه کنیم؟ پاسخ: این نمونه خطا میدهد و هیچ برنامهٔ ناقص برنمیگرداند. رفع اختلاف ساعت یا اصلاح داده، تصمیم جداگانه است و با کمکردن خودکار زمان حل نمیشود.
پرسش چهارم: آیا سه نامزد یعنی سه حذف انجامشده؟ پاسخ: خیر؛ هیچ عملی انجام نشده است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ RFC 3339 مربوط به ژوئیهٔ ۲۰۰۲ و صفحهٔ مرجع Python 3.14.8 است. اجرای ثبتشده 3.14.5 است و این آموزش پایه، گواه انطباق حقوقی یا امنیتی نیست.
منابع و مطالعهٔ بیشتر
- Python؛ زمان آگاه به منطقه و محاسبهٔ دقیق timedelta
- RFC3339؛ قالب زمان اینترنتی و محدودیت ثانیه و تاریخ
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.


