S177 — Architecture Design Document — PATCH2 (2026-05-19)
S177 — Architecture Design Document: Lark Base Controlled CRUD Gateway — PATCH2
Status: DRAFT v1.2 (PATCH2) — realigns PATCH1 to live source after S177-R0 reconcile.
Date: 2026-05-19
Supersedes: s177-architecture-design-2026-05-19-patch1.md (PATCH1, DRAFT v1.1).
Pairs with:
s177-controlled-crud-gateway-requirements-v2.md(đề bài v2.2 FINAL)s177-r0-code-reconcile-report-2026-05-19.md(R0 — 5 material + 2 partial drifts)s177-oq-decision-record-2026-05-19.md(OQ-2/-3/-4/-5/-7 closed) Author: Claude Code (Opus 4.7) Scope of PATCH2: interface-binding corrections only. No new architectural concept; all SafetyLayer / service / DI / GPG / approval / PII / 2-phase audit structure from PATCH1 is preserved. PATCH2 re-expresses PATCH1 against the actuallark-clientsource observed in R0.
PATCH2 changelog (11 changes): Q1 CLI = argparse (not Click), nested subparsers +
cmd_*dispatch · Q2 exit codes mapped to real enum 0–5 (no enum extension) · Q3 exception hierarchy realigned toLarkClientError/LarkAPIError/KeyError-wrap · Q4AuditLoggerextended with four explicit net-new methods + entry schemas + masking-safety carve-out · Q5LarkCore.write(...)public guarded entrypoint +client_tokensemantics + endpoint whitelist additions · Q6 test harness convention added net-new (conftest.py+LARK_TEST_INTEGRATION+ Base-đệm hard-assert) · Q7 OQ-7 correction — orphan backups always retained + logged, never auto-deleted inline · Q8 OQ-2/-3/-4/-5 folded as final decisions (no longer open) · Q9 packaging/entrypoint compatibility frozen oncli.lark_tool:main· Q10allowed_endpoints.yamlwrite-section format documented as observed; net-new additions enumerated · Q11 existing ungated live-API tests quarantined behind sameLARK_TEST_INTEGRATIONgate.Sections changed vs PATCH1: A (risks/sprint), B.3, B.4, C.2 (layer 7 keeps OQ-3 split; layer 8 routes via
LarkCore.write), C.3 (no change), C.6 (orphan retention), D.3 (OQ-3 fold), E (OQ-2 fold), F.1/F.3 (OQ-4 fold), G.3 (Click → argparse), G.5 (write enumeration +client_token), H.2/H.3 (test gate net-new + quarantine), I (Sprint 0 closed; Sprint 1 acceptance updated), J (OQ-2/-3/-4/-5/-7 closed; OQ-1 closed via R0; OQ-6 remains environment-only).
A. Executive Summary
Scope (unchanged from PATCH1)
Add controlled write capability (records first, then fields, then table/view schema) to the existing read-only lark-client v1.0.0, behind a mandatory 8-layer SafetyLayer, exposed through two tracks that share one Application Service Layer:
- Track B (CLI, production-grade, built first):
lark-tool records …/lark-tool fields …registered into the existing argparse CLI incli/lark_tool.py. - Track A (MCP, Cowork-interactive): custom adapter over the same service layer; production delete forbidden, Base đệm only.
Architecture (target — unchanged shape, corrected boundaries)
Cowork / MCP (Track A) Claude Code CLI / Cron (Track B — argparse cmd_records_*/cmd_fields_*)
│ │
└────────────────┬─────────────────┘
▼
Application Service Layer (lark_client/service.py)
— single write entrypoint, no duplicated write logic —
▼
SafetyLayer (lark_client/safety.py)
dry-run → approval(atomic) → backup(GPG) → audit-pre → lock →
rate-limit → PII-scan → LarkCore.write(…) → audit-post → release
▼
LarkCore (lark_client/core.py)
— existing _request kept private; new public LarkCore.write(...) is
the ONLY external write entrypoint; reuses GSM token + whitelist
+ token-bucket lock + retry; attaches client_token where supported —
▼
Lark Open API https://open.larksuite.com
Sprint plan (PATCH2)
| Sprint | Deliverable | Track |
|---|---|---|
| 0 (S177-R0) | CLOSED — code reconcile complete, 5 material + 2 partial drifts captured, PATCH2 issued | — |
| 1 | service.py (records) + safety.py (8 layers, atomic approval, orphan-backup) + LarkCore.write + AuditLogger extension + GPG backup + argparse records subparser + conftest.py + LARK_TEST_INTEGRATION gate + quarantine of existing live tests + unit/mock tests |
B |
| 2 | Custom MCP adapter over service layer + record.get/record.delete MCP |
A |
| 3 | field_manager.py (Text / Number / SingleSelect / Checkbox) + DirectusApprovalProvider swap + provider-swap contract test |
B |
| 4 | Table / base schema ops + view list/create/delete + monitoring (audit volume, audit loss, orphan sweep) + full integration | A+B |
Risk register (updated)
| # | Risk | Sev | Mitigation |
|---|---|---|---|
| R-1 | Design not validated vs live source | CLOSED (Sprint 0 done) | S177-R0 done; PATCH2 issued |
| R-2 | Audit-post fail after destructive API success | HIGH | AuditLogger.log_write_emergency on independent fd to a separate sink (§C.6, §P2-3 below) |
| R-3 | PII leaking into logs/exports | HIGH | 2 parallel PII layers; metadata-only audit; non-GPG egress blocks (§D.3, OQ-3) |
| R-4 | GPG private key on VPS | HIGH | Public-key-only on VPS; private key offline (OQ-2 confirmed) |
| R-5 | Batch partial failure / oversized batch | MED | config/lark-api-limits.yaml; record_delete_max=100; stop-and-report (OQ-5) |
| R-6 | Plugin write tools bypass SafetyLayer | HIGH | Plugin keeps read tools only or is fully replaced (OQ-4 confirmed) |
| R-7 | Cowork/MCP hitting production not Base đệm | MED | Hard app_token allowlist at adapter boundary (§F.3) |
| R-8 | Approval one-time double-spend under concurrency | MED | Atomic file-locked check-and-consume (§C.3) |
| R-9 | Orphan GPG backup if audit-pre fails after backup | LOW | Always retain + log to /var/log/lark-ops/orphan-backups.log; sweep is separate, audited, 7-day grace (OQ-7 fold, §C.6) |
| R-10 (new, R0) | Drift between design and live source | RESOLVED in PATCH2 | All five material drifts mapped to net-new design corrections (§B.3, §G.3, §C.6/§P2-3, §G.5/§P2-4, §H.3/§P2-5) |
| R-11 (new, R0) | Existing live-API tests hit Lark unconditionally | MED | Quarantined behind LARK_TEST_INTEGRATION=1 + Base-đệm token assert in Sprint 1 prerequisite step (§P2-11) |
P2-1. CLI argparse redesign (R0 drift #1)
Source baseline (from R0)
cli/lark_tool.py uses argparse with nested subparsers and per-command cmd_* functions; main(argv) does manual dispatch. Existing top-level subparser groups: registry, schema, audit. Submodules already exist for cli.dump and cli.summarize. No Click anywhere. Entry point in pyproject.toml [project.scripts]: cli.lark_tool:main. PATCH2 must NOT introduce Click, must NOT create a new entrypoint, and must NOT fork the CLI.
Design — register records and fields as new top-level subparser groups
In cli/lark_tool.py, the existing subparsers = parser.add_subparsers(dest="cmd") (or its current name) gets two new groups added by the same pattern already used by registry/schema/audit:
# inside cli/lark_tool.py (additive only)
import cli.records as records_cli # new module
import cli.fields as fields_cli # new module (Sprint 3)
records_cli.register(subparsers) # registers `records` group
fields_cli.register(subparsers) # registers `fields` group (Sprint 3)
cli/records.py (Sprint 1) follows the same cmd_* convention already in cli/lark_tool.py:
# cli/records.py (Sprint 1 — illustrative shape, NOT code to commit here)
import argparse, json, sys
from cli.lark_tool import (
EXIT_OK, EXIT_USER_ERROR, EXIT_NETWORK_API,
EXIT_INTERNAL, EXIT_PERMISSION_CONFIG, EXIT_CRED_LOST,
)
from lark_client.service import LarkWriteService, WriteContext
from lark_client.exceptions import (
LarkClientError, LarkAPIError, ApprovalError, SafetyViolation,
PartialFailureError, AuditWriteError, UnknownBaseError,
CredentialPermissionLost,
)
def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser("records", help="record write ops (CRUD)")
sp = p.add_subparsers(dest="subcmd", required=True)
pc = sp.add_parser("create", help="create one record")
pc.add_argument("base_key"); pc.add_argument("table_id")
pc.add_argument("--data", required=True, help="JSON record body")
pc.add_argument("--approval", required=True)
pc.add_argument("--no-dry-run", action="store_true")
pc.add_argument("--confirm", action="store_true")
pc.set_defaults(func=cmd_records_create)
pu = sp.add_parser("update", help="update one record")
pu.add_argument("base_key"); pu.add_argument("table_id"); pu.add_argument("record_id")
pu.add_argument("--data", required=True)
pu.add_argument("--approval", required=True)
pu.add_argument("--no-dry-run", action="store_true")
pu.add_argument("--confirm", action="store_true")
pu.set_defaults(func=cmd_records_update)
pd = sp.add_parser("delete", help="delete one record")
pd.add_argument("base_key"); pd.add_argument("table_id"); pd.add_argument("record_id")
pd.add_argument("--approval", required=True)
pd.add_argument("--no-dry-run", action="store_true")
pd.add_argument("--confirm", action="store_true")
pd.set_defaults(func=cmd_records_delete)
pg = sp.add_parser("get", help="get one record by id (read-only)")
pg.add_argument("base_key"); pg.add_argument("table_id"); pg.add_argument("record_id")
pg.set_defaults(func=cmd_records_get)
# batch_create / batch_update / batch_delete follow the same shape with
# --input <file.jsonl> in place of --data; same flag set.
def cmd_records_create(args, *, svc: LarkWriteService) -> int:
try:
ctx = WriteContext(
base_key=args.base_key,
table_id=args.table_id,
operation="record.create",
agent=_resolve_agent(args),
approval_id=args.approval,
dry_run=not args.no_dry_run,
confirmed=args.confirm,
)
outcome = svc.create_record(ctx, json.loads(args.data))
json.dump(outcome, sys.stdout); sys.stdout.write("\n")
return EXIT_OK
except ApprovalError: return EXIT_PERMISSION_CONFIG
except SafetyViolation as e: return _safety_exit(e)
except UnknownBaseError: return EXIT_USER_ERROR
except LarkAPIError: return EXIT_NETWORK_API
except CredentialPermissionLost: return EXIT_CRED_LOST
except PartialFailureError: return EXIT_INTERNAL # see §P2-2 routing table
except AuditWriteError as e: return _audit_exit(e)
except LarkClientError: return EXIT_INTERNAL
except Exception: return EXIT_INTERNAL
Rules binding all Track-B CLI work:
- No new top-level entrypoint. Only
cli.lark_tool:mainexists. New commands hook into its existingsubparsers. - Every new command file (
cli/records.py,cli/fields.py) exports aregister(subparsers)function and onecmd_*per subcommand. No new dispatch loop;cli.lark_tool.mainalready does it. - Flags are added with
parser.add_argument(...)only. No Click decorators, no@click.command, no@click.option. --no-dry-runisstore_true(defaultFalse→ dry-run ON).--confirmisstore_true.--dataand--approvalare required strings for the relevant commands.update,delete, batch-update, batch-delete on a non-buffer base additionally require--confirm(enforced inside SafetyLayer layer 1, not at argparse level, so error contract is consistent for both CLI and MCP).fields deleterequires the literal ack string"tôi hiểu không thể undo"passed via--confirm-ack(separate flag; not a boolean) — checked inside SafetyLayer.$LARK_AGENTresolution remains §G.3 PATCH1 logic; CLI layer readsos.environ.get("LARK_AGENT")and passes viaWriteContext.agent. Real write with missing/empty$LARK_AGENT→ SafetyLayer layer-1 raisesSafetyViolation. Dry-run-only convenience default (claude-code) is allowed.
P2-2. Exit code / exception contract (R0 drift #2 + partial #6, #7)
Real enum (from source, immutable in Sprint 1)
# in cli/lark_tool.py (already present in source — do not modify)
EXIT_OK = 0
EXIT_USER_ERROR = 1
EXIT_NETWORK_API = 2
EXIT_INTERNAL = 3
EXIT_PERMISSION_CONFIG = 4
EXIT_CRED_LOST = 5
Decision — DO NOT extend the enum in Sprint 1
PATCH2 maps every new failure into the existing 0–5 enum. Rationale:
- Sprint 1 stays interface-binding to current source; we don't open a separate change to
cli/lark_tool.py's exit-code module. 5is dedicated to credential-lost (CredentialPermissionLost); never reused for any other failure (including partial failure — see below).- The
WriteOutcome.statusfield (dry_run | success | partial_failure | failed | aborted) carries the operational distinction that callers need; shell scripts that need finer granularity parse the JSON on stdout, not the exit code.
Future option (NOT Sprint 1): if shell-script needs justify it, a follow-up patch could introduce EXIT_PARTIAL_FAILURE = 6 as an additive enum extension. Out of scope for PATCH2.
Routing table — exception → exit code
| Exception (Sprint-1 surface) | Parent | Bucket meaning | Exit code |
|---|---|---|---|
SafetyViolation (dry-run-not-confirmed, scope mismatch detectable by user, missing --confirm, missing $LARK_AGENT) |
LarkClientError |
user error (caller passed wrong flags or context) | EXIT_USER_ERROR (1) |
UnknownBaseError (wraps Registry.KeyError) |
LarkClientError |
user passed unknown base_key |
EXIT_USER_ERROR (1) |
LarkAPIError (status_code, code, msg — existing) |
LarkClientError |
Lark Open API returned an error after retries | EXIT_NETWORK_API (2) |
LarkClientError (generic, unmapped) |
itself | internal lark-client error | EXIT_INTERNAL (3) |
PartialFailureError (some chunks committed, some not) |
LarkClientError |
partial failure — manual rollback expected | EXIT_INTERNAL (3) + WriteOutcome.status="partial_failure" |
AuditWriteError raised pre-API (audit-pre failed) |
LarkClientError |
system invariant violated; no mutation occurred | EXIT_INTERNAL (3) |
AuditWriteError raised post-API (audit-post degraded; API already succeeded) |
LarkClientError |
success-with-warning; emergency fallback sink written | EXIT_OK (0) + WriteOutcome.status="success" + error="audit_post_degraded" |
ApprovalError (missing / expired / scope-mismatch / already-consumed / approval-lock contention) |
LarkClientError |
approval registry says no — this is a permission/config issue | EXIT_PERMISSION_CONFIG (4) |
EndpointNotAllowed (existing in exceptions.py) |
LarkClientError |
write endpoint not on whitelist | EXIT_PERMISSION_CONFIG (4) |
RateLimitExceeded (existing — internal token-bucket wait surfaced) |
LarkClientError |
local rate limiter exhausted (rare; usually retried inside core) | EXIT_INTERNAL (3) |
TokenRefreshError (existing) |
LarkClientError |
OAuth refresh failed without credential loss | EXIT_INTERNAL (3) |
CredentialPermissionLost (existing) |
LarkClientError |
credential revoked / GSM access lost | EXIT_CRED_LOST (5) |
Rules:
- Every CLI
cmd_*handler wraps its body in a singletry/exceptchain over the above exceptions in the order shown (most specific first). Unknown exceptions →EXIT_INTERNAL. EXIT_CRED_LOST(5) is reserved forCredentialPermissionLostonly. Partial failure is never code 5.EXIT_PERMISSION_CONFIG(4) covers both approval mis-config and endpoint-whitelist refusal; these are operationally the same bucket (operator must touch a YAML).audit_post_degraded(post-API audit failure where the API already mutated) returnsEXIT_OKwith a non-emptyWriteOutcome.error. This is mandated by req §9 ("the write succeeded — never re-trigger it") and is what the emergency fallback sink exists to record. Caller scripts must checkWriteOutcome.error, not just the exit code, to detect degraded audits.- The CLI prints
WriteOutcome(or read response) as JSON to stdout on every code path that returns 0. Errors print a short error JSON to stderr.
P2-3. AuditLogger extension spec (R0 drift #5)
Source baseline (from R0)
AuditLogger (lark_client/audit.py) is append-only JSONL to /var/log/lark-ops/YYYYMMDD.jsonl; the only public method is log_call(agent, command, endpoint, method, status, duration_ms, api_calls, error, request_id); _write(entry) masks credential-like strings ≥30 chars and skips the keys ts, agent, cmd. There is no log_planned, no emergency sink, no orphan log, no phase field, no idempotency_key field in the existing schema.
Net-new methods (Sprint 1)
Four methods are added to AuditLogger. They share the existing _write plumbing where safe, but each writes a distinct entry shape and (for emergency / orphan) a distinct sink.
class AuditLogger:
# existing
def log_call(self, *, agent, command, endpoint, method, status,
duration_ms, api_calls, error, request_id) -> None: ...
def log_cli_invocation(self, *, agent, argv, exit_code) -> None: ...
def _write(self, entry) -> None: ... # private — append+fsync to today's JSONL
# NEW — Sprint 1
def log_write_planned(self, ctx, *, backup_ref, audit_pre_id) -> str:
"""Phase 1. Append+fsync planned entry to PRIMARY sink. Raises AuditWriteError
on any failure; caller (SafetyLayer layer 4) MUST abort BEFORE calling the
Lark API. Returns the audit_pre_id used (UUID v4)."""
def log_write_result(self, ctx, *, audit_pre_id, outcome) -> None:
"""Phase 3. Append+fsync result entry to PRIMARY sink, correlated to
audit_pre_id. If this raises AuditWriteError AFTER the API has already
succeeded, SafetyLayer must call log_write_emergency."""
def log_write_emergency(self, ctx, *, audit_pre_id, outcome, error) -> str:
"""Independent emergency sink. Open a NEW file descriptor under
/var/log/lark-ops/EMERGENCY/<YYYYMMDD>/<ts>-<idempotency_key>.json.
MUST NOT reuse the same fd / same sink as the primary _write path,
because that is the path that just failed. fsync the new file before
returning. If even the emergency sink fails: write a single line to
stderr 'LARK-AUDIT-LOST id=<idempotency_key> reason=<...>' and raise
AuditWriteError — never silently swallow. Returns the emergency file path."""
def log_orphan_backup(self, ctx, *, backup_path, key_fingerprint, reason) -> None:
"""Metadata-only line appended+fsynced to /var/log/lark-ops/orphan-backups.log
Never contains raw PII (only path + fingerprint + idempotency_key + reason + ts).
Used by SafetyLayer when a GPG backup at layer 3 has become orphaned because
audit-pre at layer 4 failed (OQ-7: NEVER auto-delete inline)."""
Entry JSON schemas
log_write_planned (PRIMARY sink, today's JSONL):
{
"ts": "2026-05-20T03:14:15.123Z",
"phase": "planned",
"audit_pre_id": "<uuid-v4>",
"operation_id": "<uuid-v4 ≡ idempotency_key>",
"idempotency_key": "<uuid-v4>",
"agent": "claude-code",
"cmd": "records.update",
"op": "record.update",
"base_key": "88-phai-cu-base-dem",
"table_id": "tblPQ6N79EeOmnTm",
"targets": ["rec…"],
"target_count": 1,
"approval_id": "APR-001",
"backup_ref": "/var/log/lark-ops/writes/20260520/88-…__tbl…__rec…__<id>__pre.json.gpg",
"dry_run": false,
"confirmed": true,
"is_buffer_base": true
}
log_write_result (PRIMARY sink):
{
"ts": "2026-05-20T03:14:16.045Z",
"phase": "success",
"audit_pre_id": "<uuid-v4>",
"operation_id": "<uuid-v4>",
"idempotency_key": "<uuid-v4>",
"agent": "claude-code",
"cmd": "records.update",
"op": "record.update",
"base_key": "88-phai-cu-base-dem",
"table_id": "tblPQ6N79EeOmnTm",
"request_id": "<lark X-Tt-Logid or returned id>",
"lark_response_meta": {"code": 0, "msg": "ok", "http": 200},
"duration_ms": 312,
"pii": {"pii_redacted": true, "redaction_types": ["national_id_cccd"], "redacted_fields_count": 1, "detector": ["registry","pattern"]},
"outcome_status": "success",
"error": null
}
phase ∈ {"success", "failed", "partial_failure"} for the result entry.
log_write_emergency (EMERGENCY sink, separate file per call):
{
"ts": "...",
"phase": "emergency_post_audit",
"audit_pre_id": "<uuid-v4>",
"operation_id": "<uuid-v4>",
"idempotency_key": "<uuid-v4>",
"agent": "claude-code",
"cmd": "records.update",
"lark_response_meta": {"...": "as it was at moment of API success"},
"primary_audit_error": "<exception message — masked>",
"outcome_status": "success",
"error": "audit_post_degraded"
}
log_orphan_backup (/var/log/lark-ops/orphan-backups.log, append-only):
{
"ts": "...",
"idempotency_key": "<uuid-v4>",
"operation_id": "<uuid-v4>",
"backup_path": "/var/log/lark-ops/writes/20260520/...__pre.json.gpg",
"key_fingerprint": "ABCD1234EF…",
"reason": "audit_pre_failed",
"agent": "claude-code",
"op": "record.update",
"base_key": "88-phai-cu-base-dem",
"table_id": "tblPQ6N79EeOmnTm"
}
Ordering invariants (binding for SafetyLayer implementation)
log_write_plannedMUST append + fsync BEFORE the API closure runs. Iflog_write_plannedraisesAuditWriteError, the API closure MUST NOT be called.- If
log_write_plannedsucceeded butlog_write_resultfails, AND the API call did succeed,log_write_emergencyMUST be invoked. The emergency sink is a separate file (different inode, different directory) opened with a separate file descriptor — the failure mode that motivates this is "primary sink unwritable" (disk-full, EIO, permissions), so we must not retry on the same path. log_orphan_backupis invoked only from SafetyLayer when layer 3 produced a backup but layer 4 (audit-pre) failed. It MUST run before the abort propagates, because once we abort we lose the context needed to identify the orphan.log_write_emergencyandlog_orphan_backupthemselves invoke a_write_to(path, entry)helper variant that opens its own file, fsyncs, closes — they MUST NOT share the cachedtoday's JSONLhandle that may have just failed.
Masking-safety carve-out (mandatory)
_write currently masks credential-like strings ≥30 chars, skipping only ts, agent, cmd. The fields PATCH2 introduces are strings ≥30 chars but are not credentials:
| Field | Length | Why it must not be masked |
|---|---|---|
idempotency_key |
36 (UUID v4) | Used for correlation across phases, orphan log, emergency log; rollback command refers to it |
operation_id |
36 | Synonym/alias of idempotency_key; carried for forward compatibility |
audit_pre_id |
36 | Joins phase-1 ↔ phase-3 entries |
backup_ref |
path (often ≥30 chars) | Operator must be able to find the encrypted backup file from the audit log |
request_id |
varies | Joins our trace to Lark's logid for support |
key_fingerprint |
40 (SHA-1) or 64 (SHA-256) | Identifies which GPG public key encrypted a given backup |
approval_id |
e.g. APR-001 (usually <30) |
Already short — likely passes today, but explicitly whitelisted to be safe |
base_key, table_id |
variable | Operationally meaningful, never a credential |
backup_path |
path | same as backup_ref |
Change to _write's skip-list: extend from {ts, agent, cmd} to:
_MASK_SKIP_KEYS = {
"ts", "agent", "cmd",
"audit_pre_id", "operation_id", "idempotency_key",
"backup_ref", "backup_path", "key_fingerprint",
"request_id", "approval_id",
"base_key", "table_id", "phase", "op", "outcome_status",
"target_count", "duration_ms", "dry_run", "confirmed",
"is_buffer_base",
}
PII metadata fields (pii_redacted, redaction_types, redacted_fields_count, detector) are short and not credential-shaped — they will not trip the ≥30-char heuristic, but they are explicitly listed as PII-safe metadata (no raw values).
Raw-PII rule (mandatory)
Audit entries MUST NOT contain raw PII (req §6, §13, OQ-3). Specifically:
targetsMAY includerecord_id(Lark identifier) but MUST NOT include record body fields.lark_response_metais a small object of code/msg/http; if Lark returns the created record body, that body MUST NOT be inlined into the audit entry — onlyrequest_id/code/msg/http.piiis metadata only —redaction_types[],redacted_fields_count,detector[]. Neverredacted_values.
P2-4. LarkCore public guarded-write method (R0 drift #4)
Source baseline (from R0)
LarkCore.core.py has:
- private
_request(method, endpoint, *, json_data, params, timeout, _audit_cmd)— the only HTTP write path, - private
paginate(...), - endpoint whitelist loaded from
config/allowed_endpoints.yamlcovering bothread:andwrite:, withwrite:currently empty, - token-bucket rate limiter (
fcntl.flockon/var/lock/lark-api.lock+/var/lock/lark-api.state,_MAX_RPS=10), - retry on
{429, 503, ConnectionError, Timeout}×3 backoff[1, 2, 4], requestsimported inside core only.
The PATCH1 assumption that LarkCore exposes a public write method, or accepts an idempotency_key/client_token parameter, is wrong. PATCH2 introduces both as net-new.
Design — LarkCore.write(...)
class LarkCore:
# existing private _request stays unchanged in shape; PATCH2 only adds the
# public wrapper below.
def write(
self,
method: str, # "POST" | "PUT" | "DELETE" | "PATCH"
endpoint: str, # must appear in allowed_endpoints.yaml :: write:
*,
json_data: dict | None = None,
params: dict | None = None,
timeout: float | None = None,
idempotency_key: str | None = None, # UUID v4; required for record write
client_token_supported: bool = False, # endpoint-level flag (see whitelist format below)
_audit_cmd: str | None = None,
) -> dict:
"""Public guarded write entrypoint.
- Verifies endpoint is in the loaded whitelist `write:` section.
Endpoint missing → raise EndpointNotAllowed (existing exception).
- If `client_token_supported` is True, attaches `client_token` to
json_data automatically: json_data = {**(json_data or {}), 'client_token': idempotency_key}
IFF idempotency_key is provided. (Some Lark write endpoints accept
client_token as a JSON body field; some as a query param; the
per-endpoint whitelist row carries that detail — see §P2-10.)
- If `client_token_supported` is False, the idempotency_key is NOT sent
to Lark; it is the caller's job (SafetyLayer / AuditLogger) to record
it locally so retries can be deduplicated by the operator/audit.
- Reuses the existing token cache, retry policy, rate-limit lock,
and endpoint-whitelist check.
- Internally calls self._request(method, endpoint, json_data=..., params=...,
timeout=..., _audit_cmd=_audit_cmd) — no business logic duplicated.
- Returns the parsed JSON response on 2xx; raises LarkAPIError on 4xx/5xx
after retries; raises CredentialPermissionLost on credential failures
(existing behaviour of _request)."""
Boundary rules (binding)
_requeststays private. No caller outsidelark_client/core.pymay call_requestdirectly. SafetyLayer callsLarkCore.write(...).LarkReaderalready usespaginate(...)(read-only) which internally uses_request— that is fine.SafetyLayer.guard(...)is the only external caller ofLarkCore.write. Service / writer / MCP adapter / CLI all funnel throughSafetyLayer.guard(ctx, payload, api_call)whereapi_callis a closure that does exactly onecore.write(...).- No
import requestsoutsidecore.py. (Confirmed match in R0; PATCH2 keeps this rule.) - No bypassing the whitelist.
LarkCore.writeraisesEndpointNotAllowedif the endpoint is not in the loadedwrite:section. - Lint test (CI): a unit test greps the repo (excluding
core.pyandtests/) for\b_request\(andimport\s+requests\band fails if either is found in non-core code. This is the existing "no direct requests outside core" rule extended.
client_token semantics
- Endpoints that accept
client_token(per Lark Open API docs for bitable record create/update/batch_create/batch_update at time of writing):client_tokendeduplicates retries server-side. PATCH2 passesidempotency_key(UUID v4) asclient_tokenfor those endpoints automatically. - Endpoints that do NOT accept
client_token(e.g.DELETE /…/records/:idand some batch_delete variants):idempotency_keyis still generated and propagated throughSafetyLayer+ audit + backup + rollback command. The operator can use the idempotency_key to dedupe retries from the audit log. The CLI command line MUST be replayable with the same idempotency_key for a clean retry. - PATCH2 forbids assuming all endpoints accept
client_token. Per-endpoint support is declared in the whitelist (§P2-10).
allowed_endpoints.yaml :: write: — Sprint 1 additions
Net-new entries (record-class only — fields/tables/views come Sprint 3/4). Final keys and HTTP methods are as documented in Lark Open API for open.larksuite.com:
| Sprint | Endpoint (path template) | HTTP method | client_token? |
|---|---|---|---|
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records |
POST | YES |
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_create |
POST | YES |
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id |
PUT | NO* |
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_update |
POST | YES |
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id |
DELETE | NO |
| 1 | /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_delete |
POST | NO** |
* PATCH2 assumes record update does not accept client_token; verify in Sprint 0.5 doc-citation step.
** PATCH2 assumes batch_delete does not accept client_token; verify in Sprint 0.5 doc-citation step.
Each row above translates to a whitelist entry in allowed_endpoints.yaml :: write:. Format details in §P2-10.
P2-5. Test harness convention (R0 drift #9)
Source baseline (from R0)
tests/test_core.py is pytest-based. It mixes live-API integration (e.g. test_token_obtainable hits real Lark with no mock, ungated) with offline whitelist tests. There is no tests/conftest.py. There is no LARK_TEST_INTEGRATION env-gate convention. The PATCH1 "mirror existing 19/19+8/8 mocked style" was wrong — current tests are NOT a clean mocked baseline.
Design — net-new tests/conftest.py + env gate
Create tests/conftest.py with:
# tests/conftest.py (Sprint 1 — illustrative shape, NOT code to commit here)
import os, pytest
BASE_BUFFER_KEY = "88-phai-cu-base-dem"
BASE_BUFFER_TOKEN = "Nf2bb1ExXaYnlksgoyQl72GNgAc"
def _integration_enabled() -> bool:
return os.environ.get("LARK_TEST_INTEGRATION") == "1"
integration_only = pytest.mark.skipif(
not _integration_enabled(),
reason="live Lark integration test — set LARK_TEST_INTEGRATION=1 to enable",
)
def assert_buffer_base_token(token: str) -> None:
"""Hard fail if an integration test is about to touch any base other than Base đệm."""
if token != BASE_BUFFER_TOKEN:
raise AssertionError(
f"integration test refused: token {token!r} is not Base đệm "
f"({BASE_BUFFER_TOKEN!r}); production targets are forbidden."
)
def pytest_collection_modifyitems(config, items):
"""If LARK_TEST_INTEGRATION is not '1', skip every test marked 'integration'."""
if _integration_enabled():
return
skip = pytest.mark.skip(reason="LARK_TEST_INTEGRATION!=1")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip)
pytest.ini (or pyproject.toml [tool.pytest.ini_options]) declares the integration marker:
[pytest]
markers =
integration: live Lark API tests; require LARK_TEST_INTEGRATION=1 + Base đệm token
Rules binding all S177 test code
- Default
pytestrun (no env vars) runs unit/mock tests only. Every test that calls Lark is annotated@pytest.mark.integration. The conftest skips them when the env gate is off. - Integration tests MUST hard-assert Base đệm token via
assert_buffer_base_token(token)before any write/delete/schema call. Production token (YSIkb8PxOaNaozs2vwalOOcagkf) is forbidden in tests; assertion error is the fail mode. - No
requestsin tests. Tests mockLarkCore.writefor unit tests; integration tests construct a realLarkCoreinstance but only against Base đệm. - Schema / delete integration tests are Base-đệm-only, never production (req §13.4).
- Production integration tests are categorically forbidden. Even with the env gate on, the token assertion blocks production tokens. A future "production smoke" need (none currently) would require a separate sanctioned flag and a separate review.
app_tokenliterals are allowed only intests/and inconfig/bases.yaml(existing rule; PATCH2 reaffirms).- The unit/mock subset is the Sprint 1 commit gate. Integration green is required before Sprint 1 sign-off but not at every commit.
See §P2-11 for the related quarantine of the EXISTING ungated live test.
P2-6. OQ-7 orphan backup correction
OQ-7 closes as follows; PATCH2 supersedes PATCH1 §C.6 step 1 and PATCH1 §E.5:
- NO inline "delete-if-safe" path. Even when the backup is provably from this attempt and no concurrent reader is touching it, do not delete inline. The mutation never happened, but the operator may want the backup for forensic reasons.
- Always retain the encrypted orphan blob in its original location under
/var/log/lark-ops/writes/<YYYYMMDD>/…. - Always record a metadata-only line via
AuditLogger.log_orphan_backup(...)to/var/log/lark-ops/orphan-backups.log. The line is the one in §P2-3 above (no raw PII, only path + fingerprint + idempotency_key + reason + ts). - Default grace window: 7 days before any sweep is even eligible to consider a blob.
- Sweep is an explicit, audited operator action — not a cron job, not an "if safe" branch in SafetyLayer. The runbook (Sprint 4) defines:
lark-tool audit orphan-backups list(read-only — to be added in Sprint 4 as a new subcommand under the existingauditgroup)lark-tool audit orphan-backups sweep --older-than 7d --confirm-ack "..." --approval APR-…(the sweep itself goes through SafetyLayer; deletes are audited; never bulk-no-approval).
SafetyLayer.guard(...)andAuditLoggership in Sprint 1 with retention-only logic. The sweep subcommand is a Sprint-4 deliverable (matches §I monitoring sprint). Sprint 1 must not contain anyos.remove(backup_path)orunlinkcall againstwrites/blobs.
PATCH1 wording to remove in PATCH2-applied design: "If the backup file can be removed safely … → delete it" — gone. Only step 2 of PATCH1 §C.6 ("leave it and append … orphan-backups.log") survives, and it becomes the only path.
P2-7. Fold OQ decisions into final design
These are no longer open questions. PATCH2 folds them into the design as binding decisions; the OQ register in §J is updated accordingly.
OQ-2 — GPG private key (folded)
- VPS holds the public key only, fetched from GSM as
LARK_BACKUP_GPG_PUBKEY(ASCII-armored). - Private key remains offline with Huyên; never on the VPS.
- No on-VPS decrypt capability in Sprint 1 (or any sprint until a separate ratified decision changes this).
- §E in PATCH1 stands. No on-VPS
gpg --decryptever. Recovery procedure is offline.
OQ-3 — PII policy (folded)
- Guarded record writes: PII detection → log metadata only, proceed. The write itself is not blocked by PII detection.
- Plaintext /
--export/ stdout / non-GPG paths: PII detection blocks withSafetyViolation. - Audit, stdout, and the auto-generated rollback command MUST NEVER include raw PII. Audit holds metadata only (§D.4 / §P2-3); the rollback command references the encrypted backup file path.
--pii-strict(escalate guarded writes to also abort on PII detection) is deferred — not a Sprint 1 deliverable. It is a future toggle, not in §H.2 acceptance criteria.
OQ-4 — MCP topology (folded)
- Existing
@larksuiteoapi/lark-mcpplugin is acceptable only if the MCP host can hide/disable its write tools. - Allowed plugin tools (read-class only):
appTable_list,appTableField_list,appTableRecord_search, plus any read-onlyapp/tablelisting the plugin offers. - If the host cannot hide
appTableRecord_create / _batchCreate / _update / _batchUpdate,appTable_create,app_create→ the plugin is fully replaced with the custom adapter. - Every write — regardless of topology — goes through the Application Service Layer + SafetyLayer. No unguarded write path is acceptable.
- The host's hiding capability is verified concretely in Sprint 2 (not assumed); §F.3 acceptance includes the answer.
OQ-5 — record.batch_delete limit (folded)
- The actual Lark cap for
batch_deleteis unknown until verified. config/lark-api-limits.yaml :: batch.record_delete_max = 100is the conservative default.- The ceiling is raised only after (a) an official Lark Open API doc citation, or (b) a recorded Base đệm probe (no production probe). The citation/probe lands in
knowledge/dev/lark/and the YAML is updated through a normal review change. - Service-side: explicit request > ceiling is rejected (no silent truncation). Auto-chunking only applies when caller did not specify an explicit batch size, and only up to the ceiling.
OQ-7 — Orphan backup (folded — see §P2-6 above for full design)
- Always retain + log via
AuditLogger.log_orphan_backup(...). - Default grace 7 days.
- Sweep is a Sprint 4 deliverable, explicit + audited, never inline + never automatic.
P2-8. Exception alignment (R0 drifts #3 + partial #7)
Live exception hierarchy (from R0)
lark_client.exceptions:
LarkClientError (base)
├── EndpointNotAllowed
├── CredentialPermissionLost
├── TokenRefreshError
├── LarkAPIError # carries (status_code, code, msg)
└── RateLimitExceeded
Registry.get_by_key / Registry.get_by_app_token raise built-in KeyError, not a domain exception.
Net-new exceptions in Sprint 1 (all subclass LarkClientError)
# lark_client/exceptions.py — additive
class ApprovalError(LarkClientError):
"""Approval registry refused: missing / expired / scope-mismatch /
wildcard-policy-violated / one-time-already-consumed / lock-contention."""
def __init__(self, code: str, msg: str = ""):
self.code = code # 'missing','expired','scope_mismatch','wildcard_forbidden',
# 'already_consumed','approval_locked'
super().__init__(f"{code}: {msg}" if msg else code)
class SafetyViolation(LarkClientError):
"""SafetyLayer policy violation pre-API. Subtypes carried via .reason
string: 'dry_run_required','confirm_required','agent_required',
'audit_pre_failed','lock_held','pii_egress_blocked','pii_scanner_error'."""
def __init__(self, reason: str, msg: str = ""):
self.reason = reason
super().__init__(f"{reason}: {msg}" if msg else reason)
class PartialFailureError(LarkClientError):
"""Batch partially committed. .committed and .failed are lists of
record-level outcomes; rollback is manual (req §12.10)."""
def __init__(self, *, committed: list, failed: list, rollback_command: str):
self.committed = committed
self.failed = failed
self.rollback_command = rollback_command
super().__init__(f"partial_failure: ok={len(committed)} fail={len(failed)}")
class AuditWriteError(LarkClientError):
"""Audit sink write failed. .phase ∈ {'pre','post','emergency','orphan'}."""
def __init__(self, phase: str, msg: str = ""):
self.phase = phase
super().__init__(f"audit_{phase}: {msg}" if msg else f"audit_{phase}")
class UnknownBaseError(LarkClientError):
"""Service-layer wrapper around Registry KeyError when caller passed an
unknown base_key. Raised by service.py / writer.py only — NEVER by
Registry itself; Registry continues to raise KeyError (matches source)."""
def __init__(self, base_key: str):
self.base_key = base_key
super().__init__(f"unknown_base_key: {base_key!r}")
Rules
- All net-new exceptions subclass
LarkClientError. No parallel hierarchy. LarkAPIErroris referenced with the correct caps (capitalA, capitalP, capitalI). PATCH1's "LarkApiError" was a typo and is removed.Registry.KeyErroris wrapped at the service-layer boundary. Insidelark_client/service.py,registry.get_by_key(base_key)is wrapped:
This keepstry: app_token = self._registry.get_by_key(base_key).app_tokenexcept KeyError as e: raise UnknownBaseError(base_key) from eRegistryitself untouched and matches source behaviour; only the service layer translates to a domain exception.- Do not invent exceptions not declared above.
LarkClientErrorsubclasses listed here are the complete Sprint-1 net-new set.
P2-9. Packaging / entrypoint compatibility
pyproject.toml [project.scripts]entry stays exactlycli.lark_tool:main. PATCH2 does not change packaging metadata, does not add a newconsole_scriptsentry, does not rename the entry, does not introduce a separatelark-writeorlarksafebinary.- No CLI fork.
lark-toolremains the single executable. New commands (records,fields) hook intocli.lark_tool.main's existing argparsesubparsers(§P2-1). - No version bump required for PATCH2 (PATCH2 is design only). Sprint 1 code may bump
version.txtto1.1.0or similar at commit time; that is a Sprint 1 implementation choice, not a design requirement. - No new dependencies are introduced by PATCH2's design. Sprint 1 implementation may add
python-gnupg(or shell out togpg) for backup encryption — that decision lands in the Sprint 1 command package, not here.
P2-10. allowed_endpoints.yaml write: format
Format observed in source (R0)
R0 confirmed: config/allowed_endpoints.yaml has both a read: section (populated — tables/fields/views/records/search/workflows) and a write: section (currently []). LarkCore loads both and uses them in the whitelist check inside _request. R0 did not disclose whether each list entry is a bare string (an endpoint path / regex / template) or a structured object — PATCH2 does not invent a new schema and treats the observed shape as authoritative.
PATCH2 rule
-
Do not invent a new YAML schema for
write:. Net-new write endpoints are added in the same shape the existingread:entries use. -
If the existing shape is bare strings (path templates): Sprint 1 adds the six record-class endpoints (§P2-4 table) as bare string entries under
write:. Theclient_tokensupport flag is then declared in a sibling fileconfig/lark-api-limits.yaml :: write_endpoint_options:(additive, does not modify existing schema):# config/lark-api-limits.yaml (Sprint 1) rate: requests_per_sec: 10 batch: record_create_max: 500 record_update_max: 500 record_delete_max: 100 write_endpoint_options: # endpoint_path_template -> per-endpoint metadata "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records": methods: ["POST"] client_token: true "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_create": methods: ["POST"] client_token: true "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id": methods: ["PUT","DELETE"] client_token: false "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_update": methods: ["POST"] client_token: true "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_delete": methods: ["POST"] client_token: false -
If the existing shape is structured objects (dicts with method + client_token fields): the
write_endpoint_options:block above collapses into per-entry fields underwrite:, andlark-api-limits.yamlonly carries rate/batch. Sprint 1 picks the layout that matches whatallowed_endpoints.yamlalready does. -
Decision deferred to Sprint 0.5 doc-citation step (a 30-minute read of the existing file). PATCH2 captures both shapes and is unambiguous about which fields each carries; Sprint 1 implementation picks the shape that already lives in the file. Either way, no new schema is invented; the format is observed and matched.
-
Backward compatibility: if
write_endpoint_options:is added tolark-api-limits.yaml,LarkCorereads it optionally — absence means "all whitelisted endpoints treated asclient_token=False" so existing read-only deployments are unaffected.
P2-11. Existing live-test quarantine (R0 drift #9 carry-forward)
Risk (from R0)
tests/test_core.py already contains live-API tests that run with no env gate (test_token_obtainable hits real Lark). A new developer running pytest cold today already calls Lark and burns credentials/quotas without consent.
Decision — Option A (preferred): quarantine in Sprint 1 prerequisite step
Sprint 1 includes a prerequisite micro-step (before any new write code touches service.py / safety.py) that:
- Adds
tests/conftest.pyper §P2-5. - Adds
pytest.ini/[tool.pytest.ini_options]markers per §P2-5. - Walks
tests/test_core.pyand identifies every test that calls real Lark — currentlytest_token_obtainableplus any others discovered in the same file. Each such test gets:
This change is minimal — no test body is modified; only a decorator is added.@pytest.mark.integrationdef test_token_obtainable(...): ... - Re-runs
pytestcold (no env vars) and confirms previously-live tests are now skipped, and every remaining test still passes. - Re-runs
pytestwithLARK_TEST_INTEGRATION=1and confirms the live tests run and pass against Base đệm only.
This is a non-blocking edit relative to the rest of Sprint 1: the quarantine ships in the same commit as conftest.py, before service.py / safety.py / core.write. If for any reason the quarantine cannot be done in the same Sprint 1 commit, Option B applies:
Option B (fallback)
Treat the existing ungated live tests as a baseline remediation item tracked separately, not bundled with the unit-gate. New write/integration tests in Sprint 1 still MUST be gated (they are net-new and never had a non-gated history). The existing ungated tests remain a known risk in the risk register until a follow-up patch closes them. Recommend Option A.
Hard rule (regardless of A or B)
New write/integration tests in Sprint 1 are gated by LARK_TEST_INTEGRATION=1 from day one. They never run by default. They always hard-assert Base đệm token before any write. This is non-negotiable.
Sections from PATCH1 carried forward without change
The following PATCH1 sections are still valid as-is (no R0 drift; no OQ override):
- §B.1 (
WriteOutcome,LarkWriteServiceABC,WriteContext) — interface unchanged. - §B.2 (
LarkWriteServiceconcrete) — DI + Registry resolution. - §B.4 (rate-limit & batch sizing via
lark-api-limits.yaml) — unchanged (now augmented by §P2-10 forwrite_endpoint_options:). - §C.1 (execution order of 10 layers) — unchanged.
- §C.3 (atomic approval check-and-consume + DI) — unchanged.
- §C.4 (wildcard / first-write policy) — unchanged.
- §C.5 (approval defaults per operation) — unchanged.
- §D.1 (
FieldPIIRegistry) and §D.2 (PatternPIIDetectorregex set) — unchanged. - §D.3 (PII pipeline policy) — confirmed by OQ-3;
--pii-strictdeferred (§P2-7). - §D.4 (audit redaction format) — unchanged.
- §E.1–E.4 (GPG key source, rotation, naming, recovery) — confirmed by OQ-2.
- §F.1–F.3 (MCP topology) — confirmed by OQ-4; OQ-4 reduces to a feasibility check answered in Sprint 2.
- §G.1 (
writer.pytyped façade) — unchanged. - §G.2 (
field_manager.pySprint 3 scope) — unchanged. - §G.4 (
write-approvals.yamlschema) — unchanged.
J. Open Questions (updated)
| OQ | Status | Resolution |
|---|---|---|
| OQ-1 (code reconcile) | CLOSED | S177-R0 complete; this PATCH2 is the design realignment |
| OQ-2 (GPG key) | CLOSED — folded | §P2-7: VPS public-key only; private key offline; no on-VPS decrypt in Sprint 1 |
| OQ-3 (PII policy) | CLOSED — folded | §P2-7: two-rule split; --pii-strict deferred (not Sprint 1) |
| OQ-4 (MCP topology) | CLOSED — folded (concrete feasibility check remains for Sprint 2) | §P2-7: plugin read-only-if-hideable else full replace; all writes via service + SafetyLayer |
OQ-5 (batch_delete limit) |
CLOSED — folded | §P2-7: default 100 in lark-api-limits.yaml; raise only after doc-citation or Base-đệm probe |
| OQ-6 (commit / path mechanics) | OPEN — environment-only | This patch lives in KB only. Final repo path /opt/incomex/lark-client/ not populated. Commit requires repo+shell-capable actor; outside the scope of any agent-side patch |
| OQ-7 (orphan backup) | CLOSED — folded | §P2-6: always retain + log; 7-day grace; sweep is explicit, audited, Sprint 4 |
OQ-8 (NEW — Lark client_token per-endpoint support) |
OPEN — Sprint 0.5 micro-task | §P2-4 / §P2-10 — verify per-endpoint via official Lark docs; record citation in KB; update write_endpoint_options: accordingly |
OQ-9 (NEW — exact shape of existing write: entries in allowed_endpoints.yaml) |
OPEN — Sprint 0.5 micro-task | §P2-10 — 30-min read of the existing file; pick bare-string vs structured-object shape; do not invent new schema |
OQ-6, OQ-8, OQ-9 are not blockers for issuing PATCH2 as a design. OQ-8 and OQ-9 are short read-only verifications that land in the Sprint 1 micro-prereq step alongside the conftest quarantine (§P2-11). OQ-6 is an environment limitation (no repo+shell access here) and is handled by whichever actor commits the design.
Final design status (binding statement)
- Design commit: RECOMMENDED. PATCH2 closes every material drift in R0 and folds every OQ decision. The architecture (SafetyLayer, service DI, GPG backup, atomic approval, PII layers, 2-phase audit) is unchanged from PATCH1; only the interface bindings have been corrected. Commit gate is OQ-6 (environment-only).
- Sprint 1 implementation package: CAN BE PREPARED NEXT. PATCH2 is sufficient to author the Sprint 1 command-review document (file list, exact API surfaces, conftest text, exception subclasses, exit-code routing table, endpoint whitelist additions,
lark-api-limits.yamlcontent, test inventory T1–T12 + b-variants). Preparation of that package is itself the next sanctioned step; it requires its own GPT/User authorization and does not start here. - Sprint 1 code: NOT AUTHORIZED. Sprint 1 code authoring requires (a) PATCH2 acceptance by GPT/User, (b) a Sprint 1 command-review package approved separately, (c) OQ-6 commit mechanics resolved by a repo+shell actor.
- No code, no source mutation, no commit, no Lark write, no deploy, no self-advance were performed in PATCH2.
End of S177 Architecture Design — PATCH2. DRAFT v1.2, KB-only, awaiting GPT/User acceptance. Not commit-ready until OQ-6 environment access is available. Sprint 1 code remains gated.